diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..6313b56c --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 07786026..10e7f228 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,6 +5,7 @@ on: branches: - main - next + - alpha permissions: id-token: write @@ -42,8 +43,8 @@ jobs: jq -r --arg VER "$VEROUT" '.version = $VER' package.json > /tmp/package.json && mv /tmp/package.json ./package.json working-directory: ./dist - - name: Resolve version (next — prerelease build bump) - if: github.ref == 'refs/heads/next' + - name: Resolve version (next/alpha — prerelease build bump) + if: github.ref == 'refs/heads/next' || github.ref == 'refs/heads/alpha' run: | set -x BRANCH=${GITHUB_REF##*/} @@ -75,3 +76,10 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} working-directory: ./dist + + - name: Publish (alpha) + if: github.ref == 'refs/heads/alpha' + run: npm publish --provenance --access public --tag alpha + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + working-directory: ./dist diff --git a/.gitignore b/.gitignore index fb34d256..0b095e5e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,14 @@ logs .patches codex.patch .env +TODO .codex -.playwright-mcp \ No newline at end of file +.playwright-mcp +package-lock.json +refactor_docs/ +neutral-criteria-task.md +.claude/settings.json +/.gigacode/ +/GIGACODE.md +openharness-fixture +.ai-notes diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 3fa5069d..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "typescript.tsdk": "node_modules\\typescript\\lib", -} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index a27ce48f..600daeeb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,123 @@ -### Agent Instructions - -* **Install Required Modules**: Ensure all necessary dependencies are installed before proceeding. -* **Run the Project**: Launch the development server to confirm that the application starts correctly. -* **Execute the Build Process**: Run the build to verify that everything compiles without errors. -* **Use TSX for Execution**: Follow the TSX-based startup method when running the project. -* **Write Documentation Immediately**: All relevant documentation must be added to the `docs/` directory as you work. -* **Follow Best Practices for Naming and Comments**: All variable names, function names, and comments must follow clean code principles and be written in English. +# Adminizer - Project Context + +## Project Overview + +Adminizer is a framework-agnostic admin panel for Node.js. +It includes a TypeScript backend and a React frontend, and can be integrated into server-side applications with different architectures. + +## Key Characteristics + +- Type: Node.js library/module +- Backend: TypeScript (ESM) +- Frontend: React + TypeScript + Vite +- UI stack: Radix UI, Tailwind CSS v4, shadcn/ui style components +- ORM support: Sequelize (active), TypeORM (experimental) +- License: MIT + +## Current Development Focus + +- Primary ORM: Sequelize +- TypeORM support exists but is experimental; keep Sequelize as the default recommendation +- For model adapter changes, prioritize `src/lib/model/adapter/sequelize.ts` + +## Directory Structure + +```text +adminizer/ +|-- src/ # Main source code +| |-- assets/ # Frontend (React/TSX/CSS) +| |-- controllers/ # Express controllers +| |-- helpers/ # Utilities +| |-- interfaces/ # Types/interfaces +| |-- lib/ # Core modules +| |-- models/ # Internal models +| |-- policies/ # Access policies +| |-- system/ # System wiring +| `-- index.ts # Main entry +|-- fixture/ # Demo app for local development +|-- modules/ # Extendable modules +|-- controls/ # Custom controls +|-- docs/ # Documentation +|-- test/ # Vitest tests +|-- translations/ # i18n resources +`-- dist/ # Build output +``` + +## Build and Run Commands + +### Development + +```bash +npm run dev +npm run dev:no-seed +npm run dev:typeorm +npm run dev:typeorm:no-seed +npm run dev:cors +``` + +### Start + +```bash +npm run start +npm run start:seed +npm run start:typeorm +npm run start:typeorm:seed +``` + +### Build + +```bash +npm run build +npm run build:backend +npm run compile:backend +npm run compile:ui +npm run build:assets +npm run build:module +``` + +### Tests + +```bash +npm run test +npm run test:watch +``` + +## Environment Variables + +See `.env.example`. +Important variables include: + +- `OPENAI_API_KEY` +- `ADMIN_LOGIN` +- `ADMIN_PASS` +- `JWT_SECRET` +- `AP_PASSWORD_SALT` +- `ORM` +- `NO_SEED_DATA` +- `ADMINIZER_ENV` + +## Development Conventions + +- Use TypeScript and keep identifiers in English +- Keep comments concise and in English +- Follow existing project architecture and naming style +- Use axios for frontend HTTP requests (CSRF handling is already integrated) + +## Rules for AI Agents in This Repository + +1. Do not create commits unless the user explicitly asks. +2. Write commit messages in English when commits are requested. +3. Reply in Russian in chat communication. +4. Do not start the development server unless the user explicitly asks. +5. After code changes, run `npm run compile:backend` by default. +6. Update documentation only when the user explicitly asks. +7. Do not translate technical artifacts (paths, keys, code identifiers). +8. rg is not available in the environment, use PowerShell search. + +## Completion Checklist + +Before finishing a coding task: + +1. Ensure changed code is consistent with Sequelize-first direction. +2. Run `npm run compile:backend` (unless user requested a different verification flow). +3. Report what was changed and any checks that were not run. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..da27ac83 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,182 @@ +# Changelog + +## Unreleased — hidden list fields stay on the server + +* **Fields hidden with `visible: false` no longer leave the server.** The list page only dropped + them from the table header: its rows still carried every field of the model, so the password + hash of `User` sat in the HTML of `/model/User`. The rows of the list, the export and the public + feed now carry the visible fields only (the primary key is always there). Filters and sorting + can still use hidden fields. +* **Breaking for exports:** hidden fields are no longer exported, neither by default nor when + asked for in `selectedFields`. Make a field visible in `list.fields` to export it. +* The public feed honours `list.fields` on top of `fields`, as the list does. +* `apiKey` is hidden in the default `User` list. + +## Unreleased — configurable request body limit + +* **`bodyLimit` config option** — the JSON and urlencoded body parsers took body-parser's fixed + `100kb`, so a project could not post a file of a few megabytes as JSON (a catalog import ended in + a plain `500 Internal Server Error`). `bodyLimit` (bytes or `"20mb"`) sets it; left out, the + limit stays `100kb`. The parsers run before the access policies, hence no raised default. + +## Unreleased — record-level access hardening + +Security fixes around `userAccessRelation` and the new `accessGraph`. Behaviour that changes for +existing projects is listed under **Breaking**, each with what to do about it. + +### Added + +* **`accessGraph` (experimental)** — relationship-based record access (ReBAC): each key declares + one access graph — a root model carrying memberships plus the models that inherit its visibility + down explicit parent edges. See + [User-Owned Records & Memberships](docs/AccessRights/user-owned-records.md) and the + [Record Access Cheat Sheet](docs/AccessRights/record-access-cheatsheet.md). + The feature is marked `@beta`: its configuration format and access semantics may change in a + minor release, and a project that declares `accessGraph` logs a warning at boot naming the + graphs in use. +* **Membership form of `userAccessRelation`** — `{field, through, via[, group]}` filters records + by the targets the user holds a membership row for; with `group`, a membership only counts for + the actions whose CRUD token that group carries. +* **Record-level access on populated associations.** Populated records of access-restricted + models are verified on every read; those out of reach collapse to a bare primary key. For + single associations the confinement is compiled into the populate JOIN's `ON` clause where the + adapter supports it (Sequelize `belongsTo`; TypeORM `many-to-one` with a declared FK column) — + no extra query, and the closed fields never leave the database. Other associations are + verified post-read with one batched query per associated model per request. +* **`transfer--ownership` token** — registered for every model declaring + `userAccessRelation`, granted to nobody by default; its holders may set the access relation + explicitly instead of inheriting their own. +* Change history is now filtered by record access; a user's own actions always stay visible. +* **Runnable `accessGraph` demo in the fixture** — `fixture/apps/project-graph/` packages the + `Project → Task → Message` chain (models, graph, per-project roles and demo data) as an + Adminizer app; `ENABLE_PROJECT_GRAPH=false` removes it entirely. +* **Sidebar item counts.** `badge` on `navbar.additionalLinks[*]`, `ctx.adminLink({...})` and any + item returned by `handleAdditionalLinks` is drawn as a `SidebarMenuBadge` in the expanded + sidebar (never in the collapsed rail). It is a number/string, or a resolver + `(user) => count | Promise` evaluated per request for the items the user may open, so + an app owns both its link and its count; a resolver that throws is logged and renders nothing. + Menus without it render exactly as before. +* **Breadcrumbs from page props.** Any Inertia page — app modules included — may send + `breadcrumbs: [{title, href?}]`; the header renders them (the last crumb needs no `href`). +* **Navigation registry powers breadcrumbs and search.** `AdminLinkHandler` gains + `search(user, query)` (the assistant's `searchAdminLinks` now delegates to it; + `AiAssistantAdminLink` stays as an alias of `AdminLinkSearchResult`) and `locate(user, url)`, + the breadcrumb chain of a url built from menu items, app links and link templates. With + `navbar.breadcrumbs: 'auto'` pages that send no `breadcrumbs` receive that chain as a lazily + evaluated shared prop; the default `'manual'` changes nothing. +* `GET {routePrefix}/api/links/search?q=` — JSON proxy of the registry search, filtered by the + caller's permissions. +* **A model hidden from the navbar is hidden from the registry.** `navbar.visible: false`, or + `navbar.groupsAccessRights` naming groups the user is not in, now also suppresses the model's + automatic `model--edit` / `model--add` link templates, so the model is absent + from the global search, the assistant's navigation and auto breadcrumbs. Previously only the + menu item was hidden and the assistant could still open such a model through its template. +* **Global search (Ctrl/Cmd+K)** in the header, backed by the same registry. It also handles the + assistant's `search-admin-links` browser action, which previously had no listener. +* **`moduleLayout: 'bare'`** for app pages: the `module` page drops its content margin, so an + edge-to-edge canvas, log or diff viewer no longer fights it with negative margins. +* **`--warning` / `--warning-foreground`** palette tokens (light and dark) with the + `--color-warning` theme mapping; `Badge` gains `variant="warning"`. + +### Breaking / security + +* **Restricted records no longer leak through populated associations.** Reading a model whose + relation points at a `userAccessRelation`/`accessGraph`-restricted model used to return the + full populated record even when the user could not reach it; now such records arrive as their + bare primary key. There is no off switch. *Frontends reading `record.relation.field` must + handle the bare-id form — the same form the relation already has when unpopulated.* +* **Write rules now run on update, not only on create.** A non-administrator can no longer + change the owner, group or graph of an existing record: the value is stripped (string form) or + rejected with `Access denied` (via/membership forms). *If handing records over is a legitimate + workflow, grant the model's `transfer--ownership` token.* +* **Administrators no longer have the access column stamped automatically.** Previously an admin + creating a record on a Group-restricted model either got their own group stamped or an + exception when they had none. Now the value they send is kept as is — including an empty one, + which produces a record no restricted user can see. *A warning is logged; check for records with an empty + access column after upgrading.* +* **`{in: undefined}` and `{in: null}` fail closed.** An absent IN operand used to be dropped + silently, which widened the query; it now matches nothing on both the Sequelize and TypeORM + adapters. *A user with no groups on a Group-restricted model now sees nothing instead of + everything.* +* **`intersects` throws** on both adapters. It was only ever emitted by the unsupported + collection form of `userAccessRelation`, where it produced a wrong (widened) query. +* **`DataAccessor.getFieldsConfig()` denies access again.** Since the switch to an asynchronous + `hasPermission` it always returned a truthy promise, so every user received the field config; + the synchronous `hasStaticPermission` restores the check. *Users without the model's CRUD token + stop seeing its fields.* +* **`registration.defaultUserGroup` is compared case-insensitively.** A capitalised group name in + the config used never to match, so guarded fields stayed visible to the default group; they are + hidden now. +* **Model CRUD tokens may no longer carry a contextual `check`.** Such a token is denied by every + synchronous path and would silently hide all fields of the model; registering one now throws. +* **Token ids are emitted in canonical lowercase.** The runtime already normalised them, but + project tests stubbing `accessRightsHelper` must now stub `hasStaticPermission` (not + `hasPermission`) and expect lowercase ids. + +### Breaking / data safety — delete cascade + +The Sequelize adapter deletes related records in the application. Two of its rules were wrong and +destroyed records the deleted row did not own: + +* **Deleting one record no longer deletes its parent.** `destroyOne` cascaded into `belongsTo` + associations, so removing a task removed its project (and orphaned every sibling). `destroy` + (many) already skipped parents; the two paths now share one implementation. +* **Deleting a record no longer deletes the far side of a many-to-many relation.** It used to + destroy the associated rows themselves — deleting one post deleted a tag another post still + used. Only the link rows in the through model are removed now. +* Children (`hasOne` / `hasMany`) are still deleted, but in one statement per association instead + of one per row. Row-level `beforeDestroy`/`afterDestroy` hooks are preserved for target models + that declare them (detected via `Model.hasHook`); models without hooks get a single bulk + `DELETE`. + +### Fixed + +* **Reads no longer issue a query per association per row.** `_find` called every association + getter on every returned row and discarded the result — a page of 50 records with 4 + associations cost 201 queries instead of 1. Sequelize getters never write into `dataValues`, + so the output is unchanged (associations come from the `include` of the main query). +* Change-history lookups are batched: display names and related-id checks now issue one query per + model instead of one per row. +* Media manager variants are loaded with one query instead of one per variant; catalog item + lookups resolve in parallel. +* `pushdown: true` now honours `paranoid` models and falls back to materialization for models + carrying a `defaultScope`, so both modes return the same records. +* **A `pushdown: true` that does nothing is no longer silent.** Every fallback to materialization + (a `defaultScope` on the chain, a model on another connection, an unresolvable attribute, an + adapter without pushdown — TypeORM has none) returns the same records, so an inert flag used to + be undetectable outside the SQL log. The first fallback per model now logs a warning naming the + graph, the model and the reason; it repeats after a recompile, and silence means the chain + compiled. +* A record-access filter no longer overwrites a caller's filter on the same field — the two are combined + with `and`. +* **Module stylesheet recipe** (`docs/BuildingModules.md`) omitted `tailwindcss/theme.css`, so + scale-based utilities (`p-4`, `text-sm`, `rounded-md`…) were silently not generated, and its + `--radius: var(--radius)` line compiled to a self-referencing `:root` property that squared + every corner of the panel. The example now imports the theme with `theme(reference)` and maps + `--radius-sm|md|lg|xl`. +* **`adminizer/ui/*` package export removed.** It pointed at `./dist/…`, which does not exist in + the published package (the package root *is* `dist/`), and the files behind it import the + build-time `@/` alias — the import never resolved at runtime in any release. Modules use + `window.UIComponents`; types come from `adminizer-module.d.ts`. +* **App modules receive fresh props.** The `module` page rendered the module with the props + captured when its bundle was first imported, so a partial reload (`router.reload({only})`) or a + `preserveState` visit left it stale; it now re-renders with the current page props. +* **`window.sonner.toast(...)` from an app module is visible.** The layout mounts a single + `Toaster`; the per-page copies are gone. *Modules that mounted their own `` as a + workaround must remove it, or every toast shows twice.* +* **The command palette dialog is clickable.** `CommandDialog` never passed a z-index, so the + `z-[1010]` `DialogOverlay` covered its own `z-50` content and swallowed every click. +* **A single-record update no longer widens to every record in the user's reach.** Both adapters + read field conditions from `criteria.where` when it has keys and from the top level otherwise; + injecting the access filter into `where` therefore orphaned a flat criteria such as `{id}`. + `sanitizeUserRelationAccess` now folds the flat form into `where` first. Inline list editing + hit this on every access-restricted model: it addresses the record by a bare id. +* **Inline list editing addresses the right record.** It built its criteria from + `identifierField` alone, without the primary-key fallback the rest of the controller uses, so + the key was `undefined` on any model that does not configure one. It also reported success when + record access matched nothing; it answers `404` now, instead of leaving the edited value on + screen. +* The compiled-graph cache is keyed by the model registry, so two Adminizer instances sharing a + config object cannot read each other's compilation. +* Membership lookups are memoized per request through a shared `RecordAccessCache` instead of once + per accessor. diff --git a/Readme.md b/Readme.md index 0cc6ee12..510ac924 100644 --- a/Readme.md +++ b/Readme.md @@ -8,7 +8,6 @@ The system is specifically designed to be **framework- and ORM-agnostic**, runni Thanks to this approach, Adminizer can be integrated into any server-side application, regardless of its architecture. The project is **open-source**, **free to use**, and focused on building an **open community**. -We would like to express special thanks to **Konstantin Zolotarev**, who laid the foundation with the **sails-adminpanel** project, which we later reworked and generalized. --- @@ -40,6 +39,20 @@ Key features: --- +## Architecture: Handler Registry pattern + +Extension points in Adminizer follow the **Handler Registry** pattern — a combination of *Strategy*, *Template Method* and *Registry*: + +- An **abstract handler** (e.g. `AbstractFeedbackHandler`, `AbstractCustomFilter`) declares the contract — abstract methods the user must implement, plus optional template methods built on top of them. +- A **registry** (e.g. `FeedbackHandler`, `CustomFilterHandler`) stores registered handlers by ID and dispatches calls to the matching strategy at runtime. +- Users plug in their own subclasses as runtime extensions — no rebuilds, no core changes. + +This keeps the core framework-agnostic while letting every integration swap in its own behavior. + +--- + +We would like to express special thanks to **Konstantin Zolotarev**, who laid the foundation with the **sails-adminpanel** project, which we later reworked and generalized. + > All agents must read the **AGENTS.md** file before proceeding. It contains important guidelines and responsibilities required for proper operation. [Go to the documentation](https://github.com/adminization/adminizer/blob/main/docs/index.md) \ No newline at end of file diff --git a/TODO b/TODO deleted file mode 100644 index 072748fb..00000000 --- a/TODO +++ /dev/null @@ -1,2 +0,0 @@ -Для перевода уведомлений нужно создавать instance i18n в локале юзера, для которого отправляются уведомления. -Для перевода строк уведомлений нужно в БД сохранять массив для обработки по анадлогии с (PHP) print_f('user %s has been deleted by %s', guest, admin) \ No newline at end of file diff --git a/adminizer-module.d.ts b/adminizer-module.d.ts index b125e660..4c4bc877 100644 --- a/adminizer-module.d.ts +++ b/adminizer-module.d.ts @@ -4,6 +4,9 @@ * Include this file in your module project to get full type support and * inline JSDoc hints for window.UIComponents, window.JSComponents and globals. * + * `window.UIComponents` is the only supported way to use panel components from a + * module at runtime. The package does not export component modules. + * * tsconfig.json: * ```json * { "compilerOptions": { "paths": {} }, "include": ["adminizer-module.d.ts"] } @@ -12,7 +15,6 @@ */ import type { FC, ReactNode, ComponentProps, RefObject } from 'react'; -import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; // --------------------------------------------------------------------------- // Sonner toast API (subset exposed via window.sonner) @@ -41,25 +43,52 @@ interface SonnerToast { // adminApi — typed HTTP client (mirrors src/assets/js/lib/admin-api.ts) // --------------------------------------------------------------------------- +type QueryParams = Record; + +interface HttpRequestOptions { + headers?: Record; + params?: QueryParams; + signal?: AbortSignal; + responseType?: 'arraybuffer' | 'blob' | 'document' | 'json' | 'text'; +} + +interface HttpResponse { + data: T; + status: number; + headers: Record; +} + /** * Typed HTTP client for backend calls. * * Always sends no-cache headers and appends `_ts` query param to bust caches. - * Throws a human-readable error if the session expired and server returned HTML. + * Use `*Json` methods when the endpoint must return JSON; they throw a + * human-readable error if the session expired and server returned HTML. * * @example * const { data } = await adminApi.getJson<{ items: Item[] }>('/admin/api/items'); * await adminApi.postJson('/admin/api/items', { name: 'New item' }); */ interface AdminApi { - /** GET request, returns typed AxiosResponse */ - getJson(url: string, config?: AxiosRequestConfig): Promise>; - /** POST request with JSON body */ - postJson(url: string, data?: unknown, config?: AxiosRequestConfig): Promise>; - /** PUT request with JSON body */ - putJson(url: string, data?: unknown, config?: AxiosRequestConfig): Promise>; - /** DELETE request */ - deleteJson(url: string, data?: unknown, config?: AxiosRequestConfig): Promise>; + get(url: string, options?: HttpRequestOptions): Promise>; + post(url: string, data?: unknown, options?: HttpRequestOptions): Promise>; + put(url: string, data?: unknown, options?: HttpRequestOptions): Promise>; + patch(url: string, data?: unknown, options?: HttpRequestOptions): Promise>; + delete(url: string, data?: unknown, options?: HttpRequestOptions): Promise>; + getJson(url: string, options?: HttpRequestOptions): Promise>; + postJson(url: string, data?: unknown, options?: HttpRequestOptions): Promise>; + putJson(url: string, data?: unknown, options?: HttpRequestOptions): Promise>; + patchJson(url: string, data?: unknown, options?: HttpRequestOptions): Promise>; + deleteJson(url: string, data?: unknown, options?: HttpRequestOptions): Promise>; +} + +interface AxiosCompat { + get(url: string, config?: HttpRequestOptions): Promise>; + post(url: string, data?: unknown, config?: HttpRequestOptions): Promise>; + put(url: string, data?: unknown, config?: HttpRequestOptions): Promise>; + patch(url: string, data?: unknown, config?: HttpRequestOptions): Promise>; + delete(url: string, config?: HttpRequestOptions & { data?: unknown }): Promise>; + isAxiosError(error: unknown): boolean; } // --------------------------------------------------------------------------- @@ -193,6 +222,17 @@ interface MonacoEditorProps { disabled?: boolean; } +interface HandsonTableProps { + /** Table rows. Use arrays or row objects depending on the Handsontable config. */ + data?: unknown[][] | Record[]; + /** Handsontable GridSettings. Put colHeaders, columns, rowHeaders, licenseKey, etc. here. */ + config: Record; + /** Called with the updated source data after user edits. */ + onChange: (data: unknown) => void; + /** Makes table read-only and visually dimmed */ + disabled?: boolean; +} + // --------------------------------------------------------------------------- // window augmentation // --------------------------------------------------------------------------- @@ -206,8 +246,9 @@ declare global { routePrefix: string; /** - * Typed HTTP client. Use instead of raw `window.axios` for backend calls. - * Adds no-cache headers, cache-buster param, and throws on HTML responses. + * Typed HTTP client. Use instead of legacy `window.axios` for backend calls. + * Adds no-cache headers and cache-buster params. Use `*Json` methods for + * JSON endpoints that should reject HTML responses. * * @example * const { data } = await window.adminApi.getJson<{ rows: Row[] }>(`${window.routePrefix}/api/rows`); @@ -215,9 +256,9 @@ declare global { adminApi: AdminApi; /** - * Raw Axios instance. Prefer `window.adminApi` for JSON API calls. + * Legacy Axios-compatible client. Prefer `window.adminApi`. */ - axios: AxiosInstance; + axios: AxiosCompat; /** * Sonner toast API. @@ -796,9 +837,17 @@ declare global { * Handsontable spreadsheet grid. For heavy editable data tables. * See https://handsontable.com/docs for full config. * @example - * + * */ - HandsonTable: FC<{ data?: unknown[][]; colHeaders?: string[]; rowHeaders?: boolean; licenseKey: string; [k: string]: unknown }>; + HandsonTable: FC; /** * Interactive JSON editor (vanilla-jsoneditor). diff --git a/controls/wysiwyg/ReactQuill.ts b/controls/wysiwyg/ReactQuill.ts deleted file mode 100644 index 2dbec889..00000000 --- a/controls/wysiwyg/ReactQuill.ts +++ /dev/null @@ -1,40 +0,0 @@ -import {AbstractControls, ControlType, Config, Path} from "../../../dist/lib/controls/AbstractControls"; -import {Adminizer} from "../../../dist"; - -export class ReactQuill extends AbstractControls { - readonly name: string = 'react-quill'; - readonly type: ControlType = 'wysiwyg'; - readonly path: Path = { - cssPath: `${this.routPrefix}/assets/modules/react-quill-editor.css`, - jsPath: - { - dev: "/modules/controls/wysiwyg/react-quill-editor.tsx", - production: `${this.routPrefix}/assets/modules/react-quill-editor.es.js` - } - } - readonly config: Config = {}; - - getConfig(): Config { - return this.config; - } - - constructor(adminizer: Adminizer) { - super(adminizer); - } - - getJsPath(): string { - if (process.env.VITE_ENV === 'dev') { - return this.path.jsPath.dev; - } else { - return this.path.jsPath.production - } - } - - getCssPath(): string { - return this.path.cssPath - } - - getName(): string { - return this.name - } -} diff --git a/controls/wysiwyg/react-quill-editor.css b/controls/wysiwyg/react-quill-editor.css deleted file mode 100644 index e867ba75..00000000 --- a/controls/wysiwyg/react-quill-editor.css +++ /dev/null @@ -1,7 +0,0 @@ -/*! - * Quill Editor v2.0.2 - * https://quilljs.com - * Copyright (c) 2017-2024, Slab - * Copyright (c) 2014, Jason Chen - * Copyright (c) 2013, salesforce.com - */.ql-container{box-sizing:border-box;font-family:Helvetica,Arial,sans-serif;font-size:13px;height:100%;margin:0;position:relative}.ql-container.ql-disabled .ql-tooltip{visibility:hidden}.ql-container:not(.ql-disabled) li[data-list=checked]>.ql-ui,.ql-container:not(.ql-disabled) li[data-list=unchecked]>.ql-ui{cursor:pointer}.ql-clipboard{left:-100000px;height:1px;overflow-y:hidden;position:absolute;top:50%}.ql-clipboard p{margin:0;padding:0}.ql-editor{box-sizing:border-box;counter-reset:list-0 list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9;line-height:1.42;height:100%;outline:none;overflow-y:auto;padding:12px 15px;tab-size:4;-moz-tab-size:4;text-align:left;white-space:pre-wrap;word-wrap:break-word}.ql-editor>*{cursor:text}.ql-editor p,.ql-editor ol,.ql-editor pre,.ql-editor blockquote,.ql-editor h1,.ql-editor h2,.ql-editor h3,.ql-editor h4,.ql-editor h5,.ql-editor h6{margin:0;padding:0}@supports (counter-set:none){.ql-editor p,.ql-editor h1,.ql-editor h2,.ql-editor h3,.ql-editor h4,.ql-editor h5,.ql-editor h6{counter-set:list-0 list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9}}@supports not (counter-set:none){.ql-editor p,.ql-editor h1,.ql-editor h2,.ql-editor h3,.ql-editor h4,.ql-editor h5,.ql-editor h6{counter-reset:list-0 list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9}}.ql-editor table{border-collapse:collapse}.ql-editor td{border:1px solid #000;padding:2px 5px}.ql-editor ol{padding-left:1.5em}.ql-editor li{list-style-type:none;padding-left:1.5em;position:relative}.ql-editor li>.ql-ui:before{display:inline-block;margin-left:-1.5em;margin-right:.3em;text-align:right;white-space:nowrap;width:1.2em}.ql-editor li[data-list=checked]>.ql-ui,.ql-editor li[data-list=unchecked]>.ql-ui{color:#777}.ql-editor li[data-list=bullet]>.ql-ui:before{content:"•"}.ql-editor li[data-list=checked]>.ql-ui:before{content:"☑"}.ql-editor li[data-list=unchecked]>.ql-ui:before{content:"☐"}@supports (counter-set:none){.ql-editor li[data-list]{counter-set:list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9}}@supports not (counter-set:none){.ql-editor li[data-list]{counter-reset:list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9}}.ql-editor li[data-list=ordered]{counter-increment:list-0}.ql-editor li[data-list=ordered]>.ql-ui:before{content:counter(list-0,decimal) ". "}.ql-editor li[data-list=ordered].ql-indent-1{counter-increment:list-1}.ql-editor li[data-list=ordered].ql-indent-1>.ql-ui:before{content:counter(list-1,lower-alpha) ". "}@supports (counter-set:none){.ql-editor li[data-list].ql-indent-1{counter-set:list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9}}@supports not (counter-set:none){.ql-editor li[data-list].ql-indent-1{counter-reset:list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9}}.ql-editor li[data-list=ordered].ql-indent-2{counter-increment:list-2}.ql-editor li[data-list=ordered].ql-indent-2>.ql-ui:before{content:counter(list-2,lower-roman) ". "}@supports (counter-set:none){.ql-editor li[data-list].ql-indent-2{counter-set:list-3 list-4 list-5 list-6 list-7 list-8 list-9}}@supports not (counter-set:none){.ql-editor li[data-list].ql-indent-2{counter-reset:list-3 list-4 list-5 list-6 list-7 list-8 list-9}}.ql-editor li[data-list=ordered].ql-indent-3{counter-increment:list-3}.ql-editor li[data-list=ordered].ql-indent-3>.ql-ui:before{content:counter(list-3,decimal) ". "}@supports (counter-set:none){.ql-editor li[data-list].ql-indent-3{counter-set:list-4 list-5 list-6 list-7 list-8 list-9}}@supports not (counter-set:none){.ql-editor li[data-list].ql-indent-3{counter-reset:list-4 list-5 list-6 list-7 list-8 list-9}}.ql-editor li[data-list=ordered].ql-indent-4{counter-increment:list-4}.ql-editor li[data-list=ordered].ql-indent-4>.ql-ui:before{content:counter(list-4,lower-alpha) ". "}@supports (counter-set:none){.ql-editor li[data-list].ql-indent-4{counter-set:list-5 list-6 list-7 list-8 list-9}}@supports not (counter-set:none){.ql-editor li[data-list].ql-indent-4{counter-reset:list-5 list-6 list-7 list-8 list-9}}.ql-editor li[data-list=ordered].ql-indent-5{counter-increment:list-5}.ql-editor li[data-list=ordered].ql-indent-5>.ql-ui:before{content:counter(list-5,lower-roman) ". "}@supports (counter-set:none){.ql-editor li[data-list].ql-indent-5{counter-set:list-6 list-7 list-8 list-9}}@supports not (counter-set:none){.ql-editor li[data-list].ql-indent-5{counter-reset:list-6 list-7 list-8 list-9}}.ql-editor li[data-list=ordered].ql-indent-6{counter-increment:list-6}.ql-editor li[data-list=ordered].ql-indent-6>.ql-ui:before{content:counter(list-6,decimal) ". "}@supports (counter-set:none){.ql-editor li[data-list].ql-indent-6{counter-set:list-7 list-8 list-9}}@supports not (counter-set:none){.ql-editor li[data-list].ql-indent-6{counter-reset:list-7 list-8 list-9}}.ql-editor li[data-list=ordered].ql-indent-7{counter-increment:list-7}.ql-editor li[data-list=ordered].ql-indent-7>.ql-ui:before{content:counter(list-7,lower-alpha) ". "}@supports (counter-set:none){.ql-editor li[data-list].ql-indent-7{counter-set:list-8 list-9}}@supports not (counter-set:none){.ql-editor li[data-list].ql-indent-7{counter-reset:list-8 list-9}}.ql-editor li[data-list=ordered].ql-indent-8{counter-increment:list-8}.ql-editor li[data-list=ordered].ql-indent-8>.ql-ui:before{content:counter(list-8,lower-roman) ". "}@supports (counter-set:none){.ql-editor li[data-list].ql-indent-8{counter-set:list-9}}@supports not (counter-set:none){.ql-editor li[data-list].ql-indent-8{counter-reset:list-9}}.ql-editor li[data-list=ordered].ql-indent-9{counter-increment:list-9}.ql-editor li[data-list=ordered].ql-indent-9>.ql-ui:before{content:counter(list-9,decimal) ". "}.ql-editor .ql-indent-1:not(.ql-direction-rtl){padding-left:3em}.ql-editor li.ql-indent-1:not(.ql-direction-rtl){padding-left:4.5em}.ql-editor .ql-indent-1.ql-direction-rtl.ql-align-right{padding-right:3em}.ql-editor li.ql-indent-1.ql-direction-rtl.ql-align-right{padding-right:4.5em}.ql-editor .ql-indent-2:not(.ql-direction-rtl){padding-left:6em}.ql-editor li.ql-indent-2:not(.ql-direction-rtl){padding-left:7.5em}.ql-editor .ql-indent-2.ql-direction-rtl.ql-align-right{padding-right:6em}.ql-editor li.ql-indent-2.ql-direction-rtl.ql-align-right{padding-right:7.5em}.ql-editor .ql-indent-3:not(.ql-direction-rtl){padding-left:9em}.ql-editor li.ql-indent-3:not(.ql-direction-rtl){padding-left:10.5em}.ql-editor .ql-indent-3.ql-direction-rtl.ql-align-right{padding-right:9em}.ql-editor li.ql-indent-3.ql-direction-rtl.ql-align-right{padding-right:10.5em}.ql-editor .ql-indent-4:not(.ql-direction-rtl){padding-left:12em}.ql-editor li.ql-indent-4:not(.ql-direction-rtl){padding-left:13.5em}.ql-editor .ql-indent-4.ql-direction-rtl.ql-align-right{padding-right:12em}.ql-editor li.ql-indent-4.ql-direction-rtl.ql-align-right{padding-right:13.5em}.ql-editor .ql-indent-5:not(.ql-direction-rtl){padding-left:15em}.ql-editor li.ql-indent-5:not(.ql-direction-rtl){padding-left:16.5em}.ql-editor .ql-indent-5.ql-direction-rtl.ql-align-right{padding-right:15em}.ql-editor li.ql-indent-5.ql-direction-rtl.ql-align-right{padding-right:16.5em}.ql-editor .ql-indent-6:not(.ql-direction-rtl){padding-left:18em}.ql-editor li.ql-indent-6:not(.ql-direction-rtl){padding-left:19.5em}.ql-editor .ql-indent-6.ql-direction-rtl.ql-align-right{padding-right:18em}.ql-editor li.ql-indent-6.ql-direction-rtl.ql-align-right{padding-right:19.5em}.ql-editor .ql-indent-7:not(.ql-direction-rtl){padding-left:21em}.ql-editor li.ql-indent-7:not(.ql-direction-rtl){padding-left:22.5em}.ql-editor .ql-indent-7.ql-direction-rtl.ql-align-right{padding-right:21em}.ql-editor li.ql-indent-7.ql-direction-rtl.ql-align-right{padding-right:22.5em}.ql-editor .ql-indent-8:not(.ql-direction-rtl){padding-left:24em}.ql-editor li.ql-indent-8:not(.ql-direction-rtl){padding-left:25.5em}.ql-editor .ql-indent-8.ql-direction-rtl.ql-align-right{padding-right:24em}.ql-editor li.ql-indent-8.ql-direction-rtl.ql-align-right{padding-right:25.5em}.ql-editor .ql-indent-9:not(.ql-direction-rtl){padding-left:27em}.ql-editor li.ql-indent-9:not(.ql-direction-rtl){padding-left:28.5em}.ql-editor .ql-indent-9.ql-direction-rtl.ql-align-right{padding-right:27em}.ql-editor li.ql-indent-9.ql-direction-rtl.ql-align-right{padding-right:28.5em}.ql-editor li.ql-direction-rtl{padding-right:1.5em}.ql-editor li.ql-direction-rtl>.ql-ui:before{margin-left:.3em;margin-right:-1.5em;text-align:left}.ql-editor table{table-layout:fixed;width:100%}.ql-editor table td{outline:none}.ql-editor .ql-code-block-container{font-family:monospace}.ql-editor .ql-video{display:block;max-width:100%}.ql-editor .ql-video.ql-align-center{margin:0 auto}.ql-editor .ql-video.ql-align-right{margin:0 0 0 auto}.ql-editor .ql-bg-black{background-color:#000}.ql-editor .ql-bg-red{background-color:#e60000}.ql-editor .ql-bg-orange{background-color:#f90}.ql-editor .ql-bg-yellow{background-color:#ff0}.ql-editor .ql-bg-green{background-color:#008a00}.ql-editor .ql-bg-blue{background-color:#06c}.ql-editor .ql-bg-purple{background-color:#93f}.ql-editor .ql-color-white{color:#fff}.ql-editor .ql-color-red{color:#e60000}.ql-editor .ql-color-orange{color:#f90}.ql-editor .ql-color-yellow{color:#ff0}.ql-editor .ql-color-green{color:#008a00}.ql-editor .ql-color-blue{color:#06c}.ql-editor .ql-color-purple{color:#93f}.ql-editor .ql-font-serif{font-family:Georgia,Times New Roman,serif}.ql-editor .ql-font-monospace{font-family:Monaco,Courier New,monospace}.ql-editor .ql-size-small{font-size:.75em}.ql-editor .ql-size-large{font-size:1.5em}.ql-editor .ql-size-huge{font-size:2.5em}.ql-editor .ql-direction-rtl{direction:rtl;text-align:inherit}.ql-editor .ql-align-center{text-align:center}.ql-editor .ql-align-justify{text-align:justify}.ql-editor .ql-align-right{text-align:right}.ql-editor .ql-ui{position:absolute}.ql-editor.ql-blank:before{color:#0009;content:attr(data-placeholder);font-style:italic;left:15px;pointer-events:none;position:absolute;right:15px}.ql-snow.ql-toolbar:after,.ql-snow .ql-toolbar:after{clear:both;content:"";display:table}.ql-snow.ql-toolbar button,.ql-snow .ql-toolbar button{background:none;border:none;cursor:pointer;display:inline-block;float:left;height:24px;padding:3px 5px;width:28px}.ql-snow.ql-toolbar button svg,.ql-snow .ql-toolbar button svg{float:left;height:100%}.ql-snow.ql-toolbar button:active:hover,.ql-snow .ql-toolbar button:active:hover{outline:none}.ql-snow.ql-toolbar input.ql-image[type=file],.ql-snow .ql-toolbar input.ql-image[type=file]{display:none}.ql-snow.ql-toolbar button:hover,.ql-snow .ql-toolbar button:hover,.ql-snow.ql-toolbar button:focus,.ql-snow .ql-toolbar button:focus,.ql-snow.ql-toolbar button.ql-active,.ql-snow .ql-toolbar button.ql-active,.ql-snow.ql-toolbar .ql-picker-label:hover,.ql-snow .ql-toolbar .ql-picker-label:hover,.ql-snow.ql-toolbar .ql-picker-label.ql-active,.ql-snow .ql-toolbar .ql-picker-label.ql-active,.ql-snow.ql-toolbar .ql-picker-item:hover,.ql-snow .ql-toolbar .ql-picker-item:hover,.ql-snow.ql-toolbar .ql-picker-item.ql-selected,.ql-snow .ql-toolbar .ql-picker-item.ql-selected{color:#06c}.ql-snow.ql-toolbar button:hover .ql-fill,.ql-snow .ql-toolbar button:hover .ql-fill,.ql-snow.ql-toolbar button:focus .ql-fill,.ql-snow .ql-toolbar button:focus .ql-fill,.ql-snow.ql-toolbar button.ql-active .ql-fill,.ql-snow .ql-toolbar button.ql-active .ql-fill,.ql-snow.ql-toolbar .ql-picker-label:hover .ql-fill,.ql-snow .ql-toolbar .ql-picker-label:hover .ql-fill,.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-fill,.ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-fill,.ql-snow.ql-toolbar .ql-picker-item:hover .ql-fill,.ql-snow .ql-toolbar .ql-picker-item:hover .ql-fill,.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-fill,.ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-fill,.ql-snow.ql-toolbar button:hover .ql-stroke.ql-fill,.ql-snow .ql-toolbar button:hover .ql-stroke.ql-fill,.ql-snow.ql-toolbar button:focus .ql-stroke.ql-fill,.ql-snow .ql-toolbar button:focus .ql-stroke.ql-fill,.ql-snow.ql-toolbar button.ql-active .ql-stroke.ql-fill,.ql-snow .ql-toolbar button.ql-active .ql-stroke.ql-fill,.ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke.ql-fill,.ql-snow .ql-toolbar .ql-picker-label:hover .ql-stroke.ql-fill,.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke.ql-fill,.ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-stroke.ql-fill,.ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke.ql-fill,.ql-snow .ql-toolbar .ql-picker-item:hover .ql-stroke.ql-fill,.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke.ql-fill,.ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-stroke.ql-fill{fill:#06c}.ql-snow.ql-toolbar button:hover .ql-stroke,.ql-snow .ql-toolbar button:hover .ql-stroke,.ql-snow.ql-toolbar button:focus .ql-stroke,.ql-snow .ql-toolbar button:focus .ql-stroke,.ql-snow.ql-toolbar button.ql-active .ql-stroke,.ql-snow .ql-toolbar button.ql-active .ql-stroke,.ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke,.ql-snow .ql-toolbar .ql-picker-label:hover .ql-stroke,.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke,.ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-stroke,.ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke,.ql-snow .ql-toolbar .ql-picker-item:hover .ql-stroke,.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke,.ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-stroke,.ql-snow.ql-toolbar button:hover .ql-stroke-miter,.ql-snow .ql-toolbar button:hover .ql-stroke-miter,.ql-snow.ql-toolbar button:focus .ql-stroke-miter,.ql-snow .ql-toolbar button:focus .ql-stroke-miter,.ql-snow.ql-toolbar button.ql-active .ql-stroke-miter,.ql-snow .ql-toolbar button.ql-active .ql-stroke-miter,.ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke-miter,.ql-snow .ql-toolbar .ql-picker-label:hover .ql-stroke-miter,.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke-miter,.ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-stroke-miter,.ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke-miter,.ql-snow .ql-toolbar .ql-picker-item:hover .ql-stroke-miter,.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke-miter,.ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-stroke-miter{stroke:#06c}@media (pointer:coarse){.ql-snow.ql-toolbar button:hover:not(.ql-active),.ql-snow .ql-toolbar button:hover:not(.ql-active){color:#444}.ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-fill,.ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-fill,.ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-stroke.ql-fill,.ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-stroke.ql-fill{fill:#444}.ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-stroke,.ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-stroke,.ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-stroke-miter,.ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-stroke-miter{stroke:#444}}.ql-snow,.ql-snow *{box-sizing:border-box}.ql-snow .ql-hidden{display:none}.ql-snow .ql-out-bottom,.ql-snow .ql-out-top{visibility:hidden}.ql-snow .ql-tooltip{position:absolute;transform:translateY(10px)}.ql-snow .ql-tooltip a{cursor:pointer;text-decoration:none}.ql-snow .ql-tooltip.ql-flip{transform:translateY(-10px)}.ql-snow .ql-formats{display:inline-block;vertical-align:middle}.ql-snow .ql-formats:after{clear:both;content:"";display:table}.ql-snow .ql-stroke{fill:none;stroke:#444;stroke-linecap:round;stroke-linejoin:round;stroke-width:2}.ql-snow .ql-stroke-miter{fill:none;stroke:#444;stroke-miterlimit:10;stroke-width:2}.ql-snow .ql-fill,.ql-snow .ql-stroke.ql-fill{fill:#444}.ql-snow .ql-empty{fill:none}.ql-snow .ql-even{fill-rule:evenodd}.ql-snow .ql-thin,.ql-snow .ql-stroke.ql-thin{stroke-width:1}.ql-snow .ql-transparent{opacity:.4}.ql-snow .ql-direction svg:last-child{display:none}.ql-snow .ql-direction.ql-active svg:last-child{display:inline}.ql-snow .ql-direction.ql-active svg:first-child{display:none}.ql-snow .ql-editor h1{font-size:2em}.ql-snow .ql-editor h2{font-size:1.5em}.ql-snow .ql-editor h3{font-size:1.17em}.ql-snow .ql-editor h4{font-size:1em}.ql-snow .ql-editor h5{font-size:.83em}.ql-snow .ql-editor h6{font-size:.67em}.ql-snow .ql-editor a{text-decoration:underline}.ql-snow .ql-editor blockquote{border-left:4px solid #ccc;margin-bottom:5px;margin-top:5px;padding-left:16px}.ql-snow .ql-editor code,.ql-snow .ql-editor .ql-code-block-container{background-color:#f0f0f0;border-radius:3px}.ql-snow .ql-editor .ql-code-block-container{margin-bottom:5px;margin-top:5px;padding:5px 10px}.ql-snow .ql-editor code{font-size:85%;padding:2px 4px}.ql-snow .ql-editor .ql-code-block-container{background-color:#23241f;color:#f8f8f2;overflow:visible}.ql-snow .ql-editor img{max-width:100%}.ql-snow .ql-picker{color:#444;display:inline-block;float:left;font-size:14px;font-weight:500;height:24px;position:relative;vertical-align:middle}.ql-snow .ql-picker-label{cursor:pointer;display:inline-block;height:100%;padding-left:8px;padding-right:2px;position:relative;width:100%}.ql-snow .ql-picker-label:before{display:inline-block;line-height:22px}.ql-snow .ql-picker-options{background-color:#fff;display:none;min-width:100%;padding:4px 8px;position:absolute;white-space:nowrap}.ql-snow .ql-picker-options .ql-picker-item{cursor:pointer;display:block;padding-bottom:5px;padding-top:5px}.ql-snow .ql-picker.ql-expanded .ql-picker-label{color:#ccc;z-index:2}.ql-snow .ql-picker.ql-expanded .ql-picker-label .ql-fill{fill:#ccc}.ql-snow .ql-picker.ql-expanded .ql-picker-label .ql-stroke{stroke:#ccc}.ql-snow .ql-picker.ql-expanded .ql-picker-options{display:block;margin-top:-1px;top:100%;z-index:1}.ql-snow .ql-color-picker,.ql-snow .ql-icon-picker{width:28px}.ql-snow .ql-color-picker .ql-picker-label,.ql-snow .ql-icon-picker .ql-picker-label{padding:2px 4px}.ql-snow .ql-color-picker .ql-picker-label svg,.ql-snow .ql-icon-picker .ql-picker-label svg{right:4px}.ql-snow .ql-icon-picker .ql-picker-options{padding:4px 0}.ql-snow .ql-icon-picker .ql-picker-item{height:24px;width:24px;padding:2px 4px}.ql-snow .ql-color-picker .ql-picker-options{padding:3px 5px;width:152px}.ql-snow .ql-color-picker .ql-picker-item{border:1px solid transparent;float:left;height:16px;margin:2px;padding:0;width:16px}.ql-snow .ql-picker:not(.ql-color-picker):not(.ql-icon-picker) svg{position:absolute;margin-top:-9px;right:0;top:50%;width:18px}.ql-snow .ql-picker.ql-header .ql-picker-label[data-label]:not([data-label=""]):before,.ql-snow .ql-picker.ql-font .ql-picker-label[data-label]:not([data-label=""]):before,.ql-snow .ql-picker.ql-size .ql-picker-label[data-label]:not([data-label=""]):before,.ql-snow .ql-picker.ql-header .ql-picker-item[data-label]:not([data-label=""]):before,.ql-snow .ql-picker.ql-font .ql-picker-item[data-label]:not([data-label=""]):before,.ql-snow .ql-picker.ql-size .ql-picker-item[data-label]:not([data-label=""]):before{content:attr(data-label)}.ql-snow .ql-picker.ql-header{width:98px}.ql-snow .ql-picker.ql-header .ql-picker-label:before,.ql-snow .ql-picker.ql-header .ql-picker-item:before{content:"Normal"}.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="1"]:before,.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="1"]:before{content:"Heading 1"}.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="2"]:before,.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="2"]:before{content:"Heading 2"}.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="3"]:before,.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="3"]:before{content:"Heading 3"}.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="4"]:before,.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="4"]:before{content:"Heading 4"}.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="5"]:before,.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="5"]:before{content:"Heading 5"}.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="6"]:before,.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="6"]:before{content:"Heading 6"}.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="1"]:before{font-size:2em}.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="2"]:before{font-size:1.5em}.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="3"]:before{font-size:1.17em}.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="4"]:before{font-size:1em}.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="5"]:before{font-size:.83em}.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="6"]:before{font-size:.67em}.ql-snow .ql-picker.ql-font{width:108px}.ql-snow .ql-picker.ql-font .ql-picker-label:before,.ql-snow .ql-picker.ql-font .ql-picker-item:before{content:"Sans Serif"}.ql-snow .ql-picker.ql-font .ql-picker-label[data-value=serif]:before,.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=serif]:before{content:"Serif"}.ql-snow .ql-picker.ql-font .ql-picker-label[data-value=monospace]:before,.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=monospace]:before{content:"Monospace"}.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=serif]:before{font-family:Georgia,Times New Roman,serif}.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=monospace]:before{font-family:Monaco,Courier New,monospace}.ql-snow .ql-picker.ql-size{width:98px}.ql-snow .ql-picker.ql-size .ql-picker-label:before,.ql-snow .ql-picker.ql-size .ql-picker-item:before{content:"Normal"}.ql-snow .ql-picker.ql-size .ql-picker-label[data-value=small]:before,.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=small]:before{content:"Small"}.ql-snow .ql-picker.ql-size .ql-picker-label[data-value=large]:before,.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=large]:before{content:"Large"}.ql-snow .ql-picker.ql-size .ql-picker-label[data-value=huge]:before,.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=huge]:before{content:"Huge"}.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=small]:before{font-size:10px}.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=large]:before{font-size:18px}.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=huge]:before{font-size:32px}.ql-snow .ql-color-picker.ql-background .ql-picker-item{background-color:#fff}.ql-snow .ql-color-picker.ql-color .ql-picker-item{background-color:#000}.ql-code-block-container{position:relative}.ql-code-block-container .ql-ui{right:5px;top:5px}.ql-toolbar.ql-snow{border:1px solid #ccc;box-sizing:border-box;font-family:Helvetica Neue,Helvetica,Arial,sans-serif;padding:8px}.ql-toolbar.ql-snow .ql-formats{margin-right:15px}.ql-toolbar.ql-snow .ql-picker-label{border:1px solid transparent}.ql-toolbar.ql-snow .ql-picker-options{border:1px solid transparent;box-shadow:#0003 0 2px 8px}.ql-toolbar.ql-snow .ql-picker.ql-expanded .ql-picker-label,.ql-toolbar.ql-snow .ql-picker.ql-expanded .ql-picker-options{border-color:#ccc}.ql-toolbar.ql-snow .ql-color-picker .ql-picker-item.ql-selected,.ql-toolbar.ql-snow .ql-color-picker .ql-picker-item:hover{border-color:#000}.ql-toolbar.ql-snow+.ql-container.ql-snow{border-top:0}.ql-snow .ql-tooltip{background-color:#fff;border:1px solid #ccc;box-shadow:0 0 5px #ddd;color:#444;padding:5px 12px;white-space:nowrap}.ql-snow .ql-tooltip:before{content:"Visit URL:";line-height:26px;margin-right:8px}.ql-snow .ql-tooltip input[type=text]{display:none;border:1px solid #ccc;font-size:13px;height:26px;margin:0;padding:3px 5px;width:170px}.ql-snow .ql-tooltip a.ql-preview{display:inline-block;max-width:200px;overflow-x:hidden;text-overflow:ellipsis;vertical-align:top}.ql-snow .ql-tooltip a.ql-action:after{border-right:1px solid #ccc;content:"Edit";margin-left:16px;padding-right:8px}.ql-snow .ql-tooltip a.ql-remove:before{content:"Remove";margin-left:8px}.ql-snow .ql-tooltip a{line-height:26px}.ql-snow .ql-tooltip.ql-editing a.ql-preview,.ql-snow .ql-tooltip.ql-editing a.ql-remove{display:none}.ql-snow .ql-tooltip.ql-editing input[type=text]{display:inline-block}.ql-snow .ql-tooltip.ql-editing a.ql-action:after{border-right:0;content:"Save";padding-right:0}.ql-snow .ql-tooltip[data-mode=link]:before{content:"Enter link:"}.ql-snow .ql-tooltip[data-mode=formula]:before{content:"Enter formula:"}.ql-snow .ql-tooltip[data-mode=video]:before{content:"Enter video:"}.ql-snow a{color:#06c}.ql-container.ql-snow{border:1px solid #ccc} diff --git a/copy-modules.js b/copy-modules.js deleted file mode 100644 index 59f844bb..00000000 --- a/copy-modules.js +++ /dev/null @@ -1,45 +0,0 @@ -import shell from 'shelljs'; -import path from 'path'; -import chalk from 'chalk'; - -const CONFIG = { - filesToCopy: [ - 'modules/controls/wysiwyg/react-quill-editor.es.js', - 'modules/controls/wysiwyg/react-quill-editor.css', - 'modules/test/ComponentB.es.js', - 'modules/testCatalog/button-Dk9HuYNn.js', - 'modules/testCatalog/catalogAction.es.js', - 'modules/testCatalog/Group.es.js' - ], - targetDir: path.normalize('dist/assets/modules') -}; - -function copyFiles() { - try { - shell.mkdir('-p', CONFIG.targetDir); - - CONFIG.filesToCopy.forEach(file => { - const sourceFile = path.normalize(file); - const targetFile = path.join(CONFIG.targetDir, path.basename(file)); - - if (shell.test('-f', sourceFile)) { - shell.cp(sourceFile, targetFile); - console.log(chalk.green(`Copied: ${chalk.bold(sourceFile)} → ${targetFile}`)); - } else { - console.log(chalk.yellow(`Source file not found: ${sourceFile}`)); - } - }); - - console.log(chalk.green('Copy process completed!')); - } catch (err) { - console.log(chalk.red(`Copy process failed: ${err.message}`)); - process.exit(1); - } -} - -try { - copyFiles(); -} catch (err) { - console.log(chalk.red(`Script execution failed: ${err.message}`)); - process.exit(1); -} diff --git a/docs/AccessRights/AccessRightsModelFields.md b/docs/AccessRights/AccessRightsModelFields.md index c1dee2aa..2bb2c57c 100644 --- a/docs/AccessRights/AccessRightsModelFields.md +++ b/docs/AccessRights/AccessRightsModelFields.md @@ -1,7 +1,7 @@ ### `DataAccessor` — Data Layer Mediator with Access Control -> Everything that is not prohibited is allowed, Excluding ties to the UserAP model +> Everything that is not prohibited is allowed, excluding ties to the canonical `User` model The `DataAccessor` class is a central utility for handling the interaction between users and the system's data models, while enforcing access control and configuration constraints. @@ -16,7 +16,7 @@ This class: * Filters records according to configured access rules * Dynamically includes associated models and their configurations * Automatically applies or restricts data access based on user roles and group membership -* Helps populate or restrict relation fields (e.g., `UserAP`, `GroupAP`) depending on ownership and configuration +* Helps populate or restrict relation fields (e.g., `User`, `Group`) depending on ownership and configuration * Provides unified methods for reading (`process`, `processMany`), sanitizing (`sanitizeUserRelationAccess`), and updating (`setUserRelationAccess`) data records with permission logic baked in --- @@ -115,4 +115,4 @@ In this example: * The `title` field is visible to everyone. * `internalStatus` is only shown to admins and QA team members. * `createdAt` is hidden entirely. -* Only records where the current user is the `owner` are accessible to non-admins. \ No newline at end of file +* Only records where the current user is the `owner` are accessible to non-admins. diff --git a/docs/AccessRights/access-rights-tokens.md b/docs/AccessRights/access-rights-tokens.md index ae4b8e4c..e9c16bac 100644 --- a/docs/AccessRights/access-rights-tokens.md +++ b/docs/AccessRights/access-rights-tokens.md @@ -1,33 +1,46 @@ # Access Rights -When sails-adminpanel starts, for every Model it creates 4 access rights tokens: +When Adminizer starts, every configured model gets 4 access rights tokens: - create - read - update - delete -You can also create custom access rights tokens using function `registerToken` of `AccessRightsHelper`. +You can also create custom access rights tokens for app features. > Everything that is unresolved is prohibited -These tokens can be used to give users rights to see information of specific Model, to create new models or edit it. -Also, you can use tokens to create access rights to global and inline actions, or to Model tools. -In controllers you should check access rights through `havePermission` method. +These tokens can be used to give users rights to see information of a specific model, create or edit records, access global and inline actions, or use model tools. -Example: +For an Adminizer app, register the token and protect the controller through `AppSetupContext`: -```javascript -if (sails.config.adminpanel.auth) { - if (!req.session.UserAP) { // check that user is authorized - return res.redirect(`${sails.config.adminpanel.routePrefix}/model/userap/login`); - } else if (!AccessRightsHelper.havePermission(`tokenName`, req.session.UserAP)) { // check permission - return res.sendStatus(403); - } - } +```ts +ctx.accessRight({ + id: "reports-export", + name: "Export reports", + description: "Permission to export reports", + department: "Reports", +}); + +ctx.controller({ + id: "export", + method: "post", + route: "/reports/export", + middleware: async (req, res) => { + // Use req.runtime for app capabilities. + return res.json({ success: true }); + }, + policies: [ + { type: "auth", mode: "api" }, + { type: "permission", token: "reports-export", mode: "api" }, + ], +}); ``` ### Registering Custom Tokens -Custom tokens let you restrict access to additional features beyond the standard CRUD actions. Register them when your application starts: +Custom tokens let you restrict access to additional features beyond the standard CRUD actions. Apps should register them through `ctx.accessRight()` so `AppManager` owns their lifecycle. + +For host-level startup code outside an Adminizer app, tokens can still be registered directly: ```ts adminizer.accessRightsHelper.registerTokens([ @@ -43,11 +56,124 @@ adminizer.accessRightsHelper.registerTokens([ Assign the token to a group so members inherit the permission: ```ts -await GroupAP.updateOne({ name: 'managers' }).set({ - tokens: ['reports-export'] -}) +await adminizer.modelHandler + .internal("access-rights") + .get("Group") + .update({ where: { name: 'managers' } }, { tokens: ['reports-export'] }); +``` + +## Contextual Tokens + +A contextual token assigns a group both a token and selected option IDs. The +token supplies the selectable options itself and can check access with those +trusted IDs. + +### App modules: `ctx.accessRight()` + +Register the token from `setup(ctx)`. The app owns the registration and it is +removed automatically when the app is disabled. + +```ts +ctx.accessRight({ + id: "reports-export", + name: "Export reports", + description: "Permission to export reports for selected warehouses", + department: "Reports", + getOptions: async (user) => warehousesService.getOptions(user), + check: async (user, context) => warehousesService.canAccess( + user, + context?.rights ?? [], + context?.warehouseId, + ), +}); +``` + +`getOptions(user)` runs whenever the Groups UI loads the selector, so newly +created business entities are available without restarting Adminizer. Each +option has a stable `id`, a display `name`, and optional `description`. + +An app controller invokes the single access-check API with its own context: + +```ts +const allowed = await req.runtime.accessRights.hasPermission( + "reports-export", + req.user, + {warehouseId: report.warehouseId}, +); + +if (!allowed) return res.status(403).send("Forbidden"); +``` + +### Host application and legacy controllers: `accessRightsHelper` + +Register a token once during application startup, after `adminizer.init()`. +Do not register it inside a request controller. + +```ts +adminizer.accessRightsHelper.registerToken({ + id: "reports-export", + name: "Export reports", + description: "Permission to export reports for selected warehouses", + department: "Reports", + getOptions: async (user) => warehousesService.getOptions(user), + check: async (user, context) => warehousesService.canAccess( + user, + context?.rights ?? [], + context?.warehouseId, + ), +}); ``` +In an existing host controller, `req.adminizer` remains available temporarily: + +```ts +const allowed = await req.adminizer.accessRightsHelper.hasPermission( + "reports-export", + req.user, + {warehouseId: report.warehouseId}, +); +``` + +For list, export, update, or delete filtering, retrieve rights from groups and +apply them to the database query before pagination. `null` means unrestricted +access (administrator or disabled authentication), while an empty array means +no granted options. + +```ts +const rights = req.adminizer.accessRightsHelper.getPermissionRights( + "reports-export", + req.user, +); + +if (rights !== null) { + query.where.warehouseId = rights; +} +``` + +The helper overwrites `context.rights` before calling `check`, so rights sent by +an HTTP client can never grant access. If `getOptions` or `check` throws, the +request is denied. + +### Storage and Groups UI + +Ordinary tokens remain strings: + +```json +["reports-export"] +``` + +For a token with `getOptions`, enabling its checkbox reveals a multiple-choice +selector. Its selected IDs are stored as an object in `Group.tokens`: + +```json +[ + "reports-export", + {"tokenId":"warehouse-access","rights":["stock1","stock2"]} +] +``` + +Only ordinary legacy string tokens are supported alongside contextual grants. + ## Users and Groups In Model `Users` admin or someone who has access can create user profiles and give them specific access rights by adding them to `Groups`. @@ -64,8 +190,8 @@ will be found, adminpanel will create admin profile with this credentials. If credentials in config will not be found, adminpanel will create admin with login `admin` and numeric password that will be displayed in console. -```javascript -module.exports.adminpanel = { +```ts +const config = { administrator: { login: 'string', password: 'string' diff --git a/docs/AccessRights/record-access-cheatsheet.md b/docs/AccessRights/record-access-cheatsheet.md new file mode 100644 index 00000000..0f26fecd --- /dev/null +++ b/docs/AccessRights/record-access-cheatsheet.md @@ -0,0 +1,104 @@ +# Record Access — Cheat Sheet + +Every access case `DataAccessor` covers, as *what you want* → *how you declare it*. +Each section links to the page with the full rules. + +* Field visibility → [Field-Level Restrictions](AccessRightsModelFields.md) +* Record ownership and access graphs → [User-Owned Records & Memberships](user-owned-records.md) +* Tokens → [Access Rights Tokens](access-rights-tokens.md) + +--- + +## 1. Field access — who sees which columns + +| Want | Config | +| --- | --- | +| A field visible to one group only | `fields: {salary: {groupsAccessRights: ["managers"]}}` | +| Different field sets in list / form / view | `list.fields`, `add.fields`, `edit.fields` (merged over `fields`) | +| Hide fields from "default" users | `registration.defaultUserGroup` — that group sees no field lacking an explicit `groupsAccessRights` | +| Administrator sees everything | `isAdministrator` bypasses field checks | + +The model's own primary key is always visible — otherwise references break. + +--- + +## 2. Record access — "my own records" (`userAccessRelation`) + +| Want | Config | How it works | +| --- | --- | --- | +| User sees only their own records | `userAccessRelation: "author"` (relation → `User`) | read: `{author: userId}`; create stamps the column with the current user and drops a submitted value; update never re-stamps the owner — protection is the filter alone | +| Same, but on an FK column | `userAccessRelation: "ownerId"`, with an association alias `via: "ownerId"` → `User` | as above | +| Records visible to the user's whole group | `userAccessRelation: "group"` (relation → `Group`) | read: `{group: {in: userGroupIds}}`; create requires the user to be in exactly one group, otherwise the save is rejected | +| Ownership one hop away (`Report → Project → User`) | `userAccessRelation: {field: "project", via: "owner"}` | the user's `Project`s are resolved → `{project: {in: ids}}`; on write only one of their own projects may be chosen | +| Membership in a target model through a join model | `{field: "project", through: "ProjectMember", via: "user"}` | only records whose target has a membership row are visible; create and moves are confined to the targets the user belongs to | +| Different roles in different projects | add `group: "group"` to the form above | a membership counts only for the actions whose CRUD token (`create\|read\|update\|delete--model`) its group carries; the `Group` records stay reusable role definitions and grant nothing globally | +| Arbitrary custom per-record logic | `userAccessRelationCallback(userWithGroups, record)` | | + +Declarations are validated at request time with explicit errors: `through` must have +exactly one relation to the target model, `via` must point at `User`, `group` at `Group`. + +--- + +## 3. Record access over a model graph (`accessGraph`) + +> **Experimental.** `accessGraph` may change its config format and access semantics in a minor +> release; enabling it logs a warning at boot. See +> [User-Owned Records](user-owned-records.md#access-graph-record-access-over-a-model-graph-accessgraph). + +For chains where membership is granted at the root but the leaf has no direct relation +to it — `Project → Task → Comment`: + +```ts +accessGraph: { + project: { + root: "Project", + membership: {through: "ProjectMember", via: "user", group: "group"}, + include: {Task: {parent: "project"}, Comment: {parent: "task"}, AgentRun: {parent: "task"}}, + } +} +``` + +| Want | Config | +| --- | --- | +| Declare the boundary once for the whole chain | `include: {Model: {parent: "alias"}}` — one edge per model, the parent model is derived from the alias | +| Narrow association pickers in forms too | automatic — pickers read the model through the same `DataAccessor` | +| Exempt one model from the graph | leave it out of `include` — a covered model cannot opt out, the graph wins over its own `userAccessRelation` | +| A "graph superuser" who sees everything | `bypassToken: "project-admin"`, matched against the user's global groups | +| Root-id source outside the panel (external service, raw SQL) | `resolveGraphRootIds: (user, verb) => ids \| "all" \| undefined` | +| Speed: denormalized root-id column | `graphRootField: {Comment: "projectId"}` — the read filter collapses to one step; the column is maintained by the application | +| Speed: one SQL instead of intermediate id lists | `pushdown: true` — nested subqueries emitted by the adapter; falls back to materialization across connections; the write path always uses the materialized walk | +| Extend another app's graph | `ctx.config()` — patches deep-merge per graph key | + +Both speed rows exist for one reason: by default a graph read costs one query per level of the +chain and inlines every intermediate id the user can reach into the last of them, so the filter +gets more expensive as an account grows, not as the page grows. `pushdown` is a permission rather +than a guarantee (and TypeORM has no pushdown at all) — when a chain cannot be compiled, the model +falls back to materialization and Adminizer warns once, naming the model and the reason. + +The graph is compiled and validated at boot (acyclic, every path reaches the root, a +model belongs to at most one graph). A structurally broken graph **denies** access to +its models rather than leaving them unrestricted. Global CRUD tokens remain the upper +bound: the graph narrows rows, it never grants access the token gates denied. + +--- + +## 4. Access boundaries + +| Want | Config | +| --- | --- | +| Foreign records through a populated association | nothing to configure — populated records of restricted models are always verified, and those out of reach collapse to a bare primary key. Where the adapter can, the confinement is compiled into the populate JOIN itself (no extra query); otherwise one batched query per associated model per request | +| Change history | history rows are filtered by the same access rules; a user's own actions always stay visible, otherwise their own audit trail disappears | +| Internal resolver queries | the `widgets`, `feed`, `navigation`, `history` and `data-accessor` internal scopes deliberately bypass record access — see [Internal Queries](../InternalQueries.md). The `data-accessor` allowlist is built from the config automatically and rebuilt on config-layer changes and model (un)registration | + +--- + +## 5. Ownership transfer and records left out of reach + +* **Handing a record to another owner**: the `transfer--ownership` token is + registered automatically for every model declaring `userAccessRelation` and granted + to nobody by default. Its holders may set the owner column explicitly, exactly as + administrators do. For `accessGraph` models the equivalent is the graph's + `bypassToken`. +* **A record created without an owner or parent** is legal — partial updates omit the + column — but only administrators will ever see it again. Adminizer logs a warning; declare + the relation `required` on the model to rule it out. diff --git a/docs/AccessRights/user-owned-records.md b/docs/AccessRights/user-owned-records.md index 03d23b55..c446e1b4 100644 --- a/docs/AccessRights/user-owned-records.md +++ b/docs/AccessRights/user-owned-records.md @@ -1,45 +1,48 @@ -### **User-Owned Records** - -In multi-user admin panels, it’s often essential to restrict data access based on ownership — that is, which user a record belongs to. `DataAccessor` supports this logic through model configuration by linking records to the `UserAP` model. This enables: - -* Automatically setting the `userId` field (or equivalent) on record creation -* Restricting access so that users can only read or modify their own records -* Enforcing ownership-based permission checks seamlessly -* Supporting clean separation of data in multi-tenant or user-based systems - -#### **How It Works** - -In the model config, you specify a field that connects the record to a user: - -```ts -models: { - Report: { - userAccessRelation: "author", // field that links to UserAP - fields: { - content: { type: "text" }, - author: { model: "userap" } - } - } -} -``` - +### **User-Owned Records** + +In multi-user admin panels, it’s often essential to restrict data access based on ownership — that is, which user a record belongs to. `DataAccessor` supports this logic through model configuration by linking records to the canonical `User` system model. This enables: + +* Automatically setting the `userId` field (or equivalent) on record creation +* Restricting access so that users can only read or modify their own records +* Enforcing ownership-based permission checks seamlessly +* Supporting clean separation of data in multi-tenant or user-based systems + +> For a one-table overview of every case on this page, see the [Record Access Cheat Sheet](record-access-cheatsheet.md). + +#### **How It Works** + +In the model config, you specify a field that connects the record to a user: + +```ts +models: { + Report: { + userAccessRelation: "author", // field that links to User + fields: { + content: { type: "text" }, + author: { model: "User" } + } + } +} +``` + If the user is **not an administrator**, they will only be able to access records where they are listed in the `author` field. This ownership check is enforced automatically through `sanitizeUserRelationAccess`. #### **Create vs Update Behavior** -When `userAccessRelation` points to a `UserAP` association (for example, `author`): +When `userAccessRelation` points to a `User` association (for example, `author`): * On **create**, ownership is assigned automatically via `setUserRelationAccess`: * If a non-admin sends any `author` value manually, it is ignored. * The saved value is forced to the current user ID. * On **update**, ownership is **not auto-assigned** again: * Access is still restricted to the user’s own records through `sanitizeUserRelationAccess`. + * If a non-admin sends any `author` value manually, it is stripped — a record cannot change owner through the panel. * In other words, update is protected by ownership filtering, not by re-writing the owner field. - + #### **Important Notes** -* The `userAccessRelation` field must exist in the ORM model and be an association to `UserAP`. -* The user model (e.g., `UserAP`) must be properly registered and include the required access rights tokens. +* The `userAccessRelation` field must exist in the ORM model and be an association to the canonical `User` system model. If the host ORM target is named differently, configure the adapter `systemModels` mapping. +* The user model must be properly registered and include the required access rights tokens. * Without defining `userAccessRelation`, access is controlled only via global permissions (using `AccessRightsHelper`), not per-record ownership. #### **FK + Alias Support** @@ -47,8 +50,269 @@ When `userAccessRelation` points to a `UserAP` association (for example, `author `userAccessRelation` may reference either: * a direct association field (for example, `owner`), or -* a foreign key field (for example, `ownerId`) when there is an association alias with `via: "ownerId"` that points to `UserAP`. +* a foreign key field (for example, `ownerId`) when there is an association alias with `via: "ownerId"` that points to `User`. This is useful for ORM schemas where relation metadata is stored on an alias field while the configured access field is a plain FK column. +#### **Group Ownership (string form)** + +The string form may also name an association to the canonical `Group` model. Records are then +visible to every member of the record's group, and on create the field is auto-assigned from the +user's groups — which requires the user to belong to **exactly one** group, otherwise the save is +rejected. + +### **Intermediate Ownership: `{field, via}`** + +Ownership may live one hop away from the record. If `Report.project` points at `Project`, and +`Project.owner` points at `User`, then: + +```ts +models: { + Report: { + userAccessRelation: { field: "project", via: "owner" } + } +} +``` + +restricts users to reports of the projects **they own**: at query time the intermediate model is +searched for records whose `via` field matches the current user, and the main model is filtered by +those ids. On create/update, a non-admin may only choose an intermediate record that belongs to +them. `via` must be a relation to the canonical `User` model. + +### **Memberships: `{field, through, via, group}`** + +The membership form replaces "the user **owns** the intermediate record" with "the user **is a +member of** it" — a join model decides who belongs where, and optionally with which role: + +```ts +models: { + Task: { + userAccessRelation: { + field: "project", // Task's relation to the target model (Project) + through: "ProjectMember", // membership model: one relation to Project, one to User, optionally one to Group + via: "user", // ProjectMember's relation to User + group: "group" // optional: ProjectMember's relation to Group + } + } +} +``` + +* **Visibility**: non-admins only see records whose target they have a membership row for. +* **Writes**: on create (and when changing the target field), the chosen target must be one of + the user's memberships — so a record cannot be created under, or moved to, a target they do + not belong to. +* **Per-target roles** (when `group` is set): a membership only counts for the actions whose CRUD + token (`create|read|update|delete--model`) its group carries — as a plain token id or a + structured grant (`{tokenId, rights}`) in `Group.tokens`. The same user can therefore be a + read-only "tester" in one project and an "editor" in another, while the `Group` records + themselves stay reusable role definitions: membership rows never enter `user.groups`, so they + grant nothing globally. + +Declaration rules, all validated at request time with explicit errors: + +* `field` must be a relation of the main model to the target model. +* `through` must resolve to a registered model with **exactly one** relation to that target model — + two relations (or none) are refused rather than guessed. +* `via` must be a relation of the `through` model to the canonical `User` model. +* `group`, when present, must be a relation of the `through` model to the canonical `Group` model. + +Two things to keep in mind: + +* **Global tokens remain the upper bound.** The membership filter narrows *rows*; it never grants + access the token gates denied. A user still needs the model's CRUD tokens (through their regular + groups) to enter the section at all — the membership's `group` can only restrict what those + tokens reach per target, not extend them. +* The `through` model (and `Group`, when `group` is used) is added to the `data-accessor` internal + model access scope automatically when the config is loaded; if you build the access map manually + (for example, in tests), whitelist them yourself. + +### **Access Graph: record access over a model graph (`accessGraph`)** + +> **Experimental feature.** `accessGraph` is experimental (`@beta`). Its configuration format and +> access semantics may change in a minor release, and enabling it makes Adminizer log a warning at +> boot (`[accessGraph] accessGraph is an EXPERIMENTAL feature ...`). Review the resulting record +> visibility yourself before relying on it in production; the stable alternative is a per-model +> [`userAccessRelation`](#user-owned-records). + +The membership form covers one model looking at one target model. Real schemas are chains: +`Project → Task → Comment`, `Project → Task → AgentRun`. Membership is granted once, on +`Project`, but `Comment` has no direct relation to it — only transitively through `Task`. +Instead of duplicating `userAccessRelation` (or denormalizing `projectId`) onto every model, +`accessGraph` declares the boundary once, as a top-level config section next to `models`: + +```ts +accessGraph: { + project: { // graph key — a stable contract name + root: "Project", // root model of the graph + membership: {through: "ProjectMember", via: "user", group: "group"}, // same form as above, now at the root + include: { + Task: {parent: "project"}, // Task.project → Project (direct edge to the root) + Comment: {parent: "task"}, // Comment.task → Task (transitive) + AgentRun: {parent: "task"}, + }, + } +} +``` + +Every model of the graph is then filtered without a line of its own: the root by the user's +membership root ids, each included model by the visible ids of its parent level (`Comment` → +`{task: {in: taskIds}}`) — free to declare, but not free to run; see +[the two performance options](#two-performance-options) below. +Association pickers in forms are covered by the same mechanism — the "choose a task" dropdown +reads `Task` through the same `DataAccessor`, already narrowed. + +Rules: + +* **One edge per model.** `parent` names the association alias the model uses to look at its + parent; the parent model is derived from that alias and must itself belong to the graph. Edges + are never guessed from foreign keys. +* **Membership is declared once, at the root.** With `group` set, a membership only counts for + the actions whose CRUD token (`--model`, checked against the model being + **accessed**) that group carries. +* **The graph wins over `userAccessRelation`** — once a graph covers a model, that model's own + declaration stops applying and the graph filters it. Declaring both is a configuration error — + the graph logs it as such at compile time, naming the model and both ways to resolve it. A model cannot opt out of a graph it belongs to: its records are visible only + through the parent edge, and the populate verification of that edge is skipped on exactly + that premise — an opt-out would leak the parent record through the association. To keep a + model on its own rule, leave it out of `include`. Administrators bypass the graph entirely. +* **Writes**: the chosen parent (`Comment.task`, `Task.project`) must be reachable for the + action's verb; creating in — or moving a record to — a foreign branch is refused. +* **Global tokens remain the upper bound**: the graph narrows rows, it never grants access the + token gates denied. +* **Validation is fail-loud at boot and fail-closed afterwards.** The graph is compiled and + validated on startup (acyclic, every path reaches the root, every edge exists and stays inside + the graph, a model belongs to at most one graph, exactly one membership definition per graph + key). A structurally broken graph or node denies access to its models instead of silently + leaving them unrestricted. The only fail-soft case is an `include` entry naming a model that is not + registered (yet) — such a model is unreachable through the panel anyway and joins the graph as + soon as it is registered. + +Two escape hatches, both at the root: + +* `bypassToken: "project-admin"` — users whose **global** groups carry this token see the whole + graph unfiltered. +* `resolveGraphRootIds: async (user, verb) => ids | "all" | undefined` — replaces the membership + source at the root (external service, raw SQL, custom logic) without touching the graph below + it. `"all"` is the explicit bypass; `undefined` falls back to the declared `membership`. + +#### Two performance options + +**What the default costs.** Walking the chain means one query per level before the query the +page actually asked for: "which projects is this user in", then "which tasks are in those +projects", and only then the list of comments — with every reachable task id inlined into it as +an `IN (...)` list. Nothing here depends on how many rows the page shows, and everything depends +on how much data the user has: a member of 500 projects with 50 tasks each pays for 25 000 ids to +render 25 rows, so the accounts that slow down first are the largest ones. Both options below +attack exactly that, and they combine. + +* **`graphRootField: {Comment: "projectId"}`** — a denormalized root-id column collapses the model's + read filter to one step (`{projectId: {in: rootIds}}`) instead of walking the parent chain; a + root-id column on an intermediate model shortcuts every model below it too. The column is + maintained by the application — Adminizer only filters by it. The `include` edge stays + declared and writes keep validating the chosen parent through it. +* **`pushdown: true`** — the read filter compiles into **one** nested-subquery SQL emitted by + the adapter (`... WHERE task IN (SELECT id FROM tasks WHERE project IN (SELECT project FROM + project_members WHERE user = :uid))`), so the DB planner optimizes the whole walk and no + intermediate id lists are materialized in the application. The SQL is generated from the + declaration — nothing is written by hand. It applies when every model on the path (and the + membership model) lives on the same Sequelize connection; otherwise — and for + `resolveGraphRootIds`-provided ids — the resolver falls back to per-level materialization + automatically. Group narrowing still resolves the granting group ids in the application (token + grants are JSON) and injects them into the membership subquery. Write-path validation always + uses the materialized walk. + +**A fallback is reported, not silent.** The flag is a permission, not a guarantee: any of the +cases above sends the model back to materialization, and an adapter that does not implement the +hook at all (today: TypeORM) never pushes anything down. Since the records come back the same +either way, a graph could otherwise carry `pushdown: true` for years without it doing anything. +So the first time a model falls back, Adminizer logs a warning naming the graph, the model and the +reason — `defaultScope`, another connection, an unresolvable attribute, an adapter without +pushdown: + +``` +[accessGraph] graph "project": "Comment" declares pushdown: true, but its read filter cannot be +compiled into a subquery — "Task" declares a defaultScope, which raw SQL cannot reproduce ... +Falling back to per-level materialization: the records are the same, but the read costs one query +per level of the chain plus every intermediate id inlined into the final query. +``` + +It is a warning, not an error — nothing is broken and access stays correct — and it is emitted once +per model, then again after a recompile (config change, model registration), since the answer may +have changed. Silence means the chain compiled. One trade-off runs the other way: the +per-request cache resolves each level once for the whole page, so a form with many association +pickers reuses one materialized walk, while the pushed-down subquery is re-executed inside every +statement — worth measuring on chains whose foreign keys are not indexed. + +Apps declare their graphs with the same `ctx.config()` patch they already use for model UI +config; patches deep-merge per graph key, so one app can define a root and another can extend its +`include` with its own models. + +A runnable example of exactly this lives in the fixture: `fixture/apps/project-graph/` packages +the `Project → Task → Message` chain above as an app — models, graph, per-project role groups and +demo data — and `ENABLE_PROJECT_GRAPH=false` removes it again. + This approach ensures secure and isolated access to data across users — critical for SaaS platforms, B2B applications, and any system with private user-specific content. + +## Boundaries of record access + +Record access confines **queries against the restricted model**. Three surfaces reach records by +another route and are handled separately — knowing which is which prevents assumptions that do +not hold. + +### Populated associations + +An adapter populates associations regardless of record access, so a record you may not reach can +be referenced by one you may. Adminizer therefore verifies populated records of restricted models +on every read and replaces those out of reach with their bare primary key. There is no switch: +leaving restricted records readable through a relation is a leak, so the verification is always +on (administrators excepted — no rule confines them). + +Mechanically, the cheap path is preferred and the safe path is the fallback: + +* **Pushdown** — for a single (`belongsTo`-style) association whose access filter has a plain + column shape, the confinement is compiled into the populate JOIN's `ON` clause. The database + then returns closed records as bare foreign keys directly: no extra query, and the closed + fields never leave the database. Requires the adapter to support it for that association + (Sequelize: any `belongsTo`; TypeORM: `many-to-one` with the FK declared as a real column + property). +* **Post-read verification** — everything else (collections, unsupported associations, access + filters too complex to inline) is verified after the read against the target model's own + rules: one batched query per associated restricted model per request, memoized for the + request's lifetime. + +Both paths produce the same shape: a record out of reach arrives as its bare primary key. + +The edge a graph model uses to reach its parent is never verified: a child is visible only +because its parent is, so the parent is inside the graph by construction. + +### Change history + +History rows name a record (`modelName` + `modelId`), so they are filtered by the same access +rules before being returned — one query per referenced model. A user's **own** actions always stay +visible: a delete removes the very row an access check would have to match, and hiding it would +erase the author's own audit trail. + +### Internal queries + +Internal scopes (`widgets`, `feed`, `navigation`, `history`, `data-accessor`) deliberately bypass +record access — they are the mechanism the resolvers themselves are built on. See +[Internal Queries](../InternalQueries.md). + +## Transferring ownership + +A record of a restricted model is pinned where it was created: a non-administrator can neither +stamp a foreign owner on create nor move a record out of their own reach. Where handing a +record over is a legitimate workflow, grant the model's `transfer--ownership` token — it +is registered automatically for every model declaring `userAccessRelation` and granted to nobody +by default. Its holders may set the access relation explicitly, exactly as administrators do. + +For `accessGraph` models the equivalent escape hatch is the graph's `bypassToken`: moving a +record between two graph roots you already belong to needs no extra right. + +## Records left out of reach + +Both the `userAccessRelation` string form and a graph `include` edge accept an empty value: +partial updates legitimately omit the column. A record **created** without one, however, hangs +outside every graph and membership, and only administrators will ever see it again — which looks like data loss +from the panel. Adminizer logs a warning in both cases; declare the relation `required` on the +model to rule it out entirely. diff --git a/docs/AiAssistant.md b/docs/AiAssistant.md index ef8fe38e..61e1d041 100644 --- a/docs/AiAssistant.md +++ b/docs/AiAssistant.md @@ -1,176 +1,158 @@ -# AI Assistant Integration +# AI Assistant + +Adminizer ships the infrastructure for an in-panel AI assistant: a shared chat +panel, a streaming agent transport, a permission-aware skill registry and a +navigation registry the agent can search and open. Concrete agents (the classes +that talk to an LLM) are **not** part of the core — they are contributed by an +Adminizer app. + +| Guide | Contents | +|---|---| +| [Building Agents](AiAssistant/BuildingAgents.md) | Write an agent service, stream a run, sessions, models, connection screens, prompts. | +| [Agent Skills](AiAssistant/AgentSkills.md) | Built-in server skills, adding your own, permissions, JSON-safe tool results. | +| [Admin Links & UI Methods](AiAssistant/AdminLinksAndUiMethods.md) | Let the agent search the navigation and open admin pages in the user's browser. | + +## Architecture + +```text +browser: assistant panel ──SSE──► AiAgentController ──► AbstractAiModelService (your agent) + ▲ │ + │ ui.method frames │ skills / links + └──────────────────────────────────────────────── AiAssistantAgentSkillHandler + AiAssistantUiMethodHandler + AdminLinkHandler +``` -The AI assistant feature introduces a chat experience that can be toggled from the main header. It is disabled by default and can be enabled through the admin panel configuration. +Core pieces: + +| Piece | Responsibility | +|---|---| +| `AiAssistantHandler` | Registry of model services plus legacy in-memory chat history. | +| `AbstractAiModelService` | Base class of an agent: metadata, `generateReply()`, optional agent members, and the protected helpers that reach the registries below. | +| `AiAgentController` | Streaming transport: status, meta, model switching, conversations, runs. | +| `AiAssistantAgentSkillHandler` | Server-side tools (“skills”). Built-in data skills plus app-contributed ones. | +| `AiAssistantUiMethodHandler` | Browser-side capabilities the agent may trigger (`navigate`, `search-admin-links`). | +| `AdminLinkHandler` | Standalone admin pages and parametrized page templates (`/model/Test/edit/:id`). | +| Assistant panel (`src/assets/js/ai-assistant`) | Renders the thread, tool calls, model picker, connection screens, and executes `ui.method` frames. | + +All four handlers are created in `Adminizer`'s constructor and are always +available as `adminizer.aiAssistantAgentSkillHandler`, +`adminizer.aiAssistantUiMethodHandler` and `adminizer.adminLinkHandler`. +`adminizer.aiAssistantHandler` is created lazily on first access, so a host +application may also register a model service directly with +`adminizer.aiAssistantHandler.registerModel(service)`. + +The core does not auto-bind any model. There is no `bindAiAssistant` bootstrap +step: an app registers routes, access rights, config patches and models +explicitly. ## Configuration -The `AdminpanelConfig` accepts an optional `aiAssistant` section: +`AdminpanelConfig` accepts an optional `aiAssistant` section: ```ts aiAssistant?: { - enabled: boolean; - defaultModel?: string; - models?: string[]; -} -``` - -* `enabled` — toggles the feature on or off (defaults to `false`). -* `defaultModel` — preferred model identifier that will be preselected on the client. -* `models` — list of model identifiers to register. Unknown identifiers are ignored with a warning. - -### Enabling/Disabling the Assistant - -When `enabled` is `false` or the `aiAssistant` section is omitted: - -* The `aiAssistantHandler` will not be initialized -* No AI assistant routes will be registered -* The UI toggle for the assistant will not appear -* No AI-related access tokens will be generated - -This ensures that the AI assistant subsystem has zero overhead when disabled. - -#### Fixture Configuration - -In the fixture, the AI assistant is controlled by the `ENABLE_AI_ASSISTANT` environment variable: - -```bash -# Enable AI assistant in fixture -export ENABLE_AI_ASSISTANT=true - -# Disable AI assistant in fixture (default) -export ENABLE_AI_ASSISTANT=false -# or simply omit the variable -``` - -This allows testing the application both with and without the AI assistant enabled. - -The default configuration registers the in-memory `dummy` model that always replies with `Ai-assystant dummy in deveploment`. - -The fixture configuration enables the assistant with this dummy model so the chat can be exercised locally without any external -dependencies. - -## Fixture OpenAI Agent - -The fixture now also registers an `openai` model that executes structured commands against the database. The agent expects JSON instructions and uses `DataAccessor` under the hood, so every operation is filtered by the requesting user's permissions. - -Example payload for creating a record: - -```json -{ - "action": "create", - "entity": "Example", - "data": { - "title": "Hello from the assistant", - "description": "Generated through the OpenAI agent" - } + enabled: boolean; // toggles the shared frontend assistant UI + defaultModel?: string; // model id preselected on the client + models?: string[]; // model ids exposed by the enabled app } ``` -If the user lacks the required access token (for example, `create-example-model`), the agent responds with an authorization error instead of touching the database. The `openai` fixture user (`login: openai`, `password: openai`) belongs to the administrators group, granting full access for experimentation. Regular users can be granted permissions by assigning the `ai-assistant-openai` token to their groups. - -## Backend Overview - -* `AiAssistantHandler` keeps registered model services and in-memory conversation history per user and model. -* Model services extend `AbstractAiModelService`, which automatically registers the corresponding access right (token pattern: `ai-assistant-`). -* The binder (`bindAiAssistant`) attaches the handler to `Adminizer` and registers models declared in the configuration. -* `AiAssistantController` exposes REST endpoints under `/api/ai-assistant` for: - * Listing available models (`GET /models`). - * Fetching conversation history (`GET /history/:modelId`). - * Sending prompts (`POST /query`). - * Resetting history (`DELETE /history/:modelId`). +The core default is `{enabled: false}`. Model defaults are app-owned — the core +assumes no `dummy` model. -All endpoints require the user to have the access token generated for the model. +In the fixture (`fixture/adminizerConfig.ts`) the assistant is disabled by +default; setting `enabled: true` starts the fixture with the app enabled. +Otherwise the app is still registered and can be enabled at runtime through the +module manager. -## Frontend Overview +## Access rights -* `AiAssistantProvider` handles fetching models/history, sending prompts, and exposing chat state via `useAiAssistant`. The provider also keeps the open state and last selected model in memory across Inertia navigations so the dialog stays available while browsing. -* `AiAssistantToggle` renders the sparkles button in the header and toggles the assistant workspace. The button displays a spinner while a request is in flight and disables itself if no models are accessible. -* `AiAssistantViewport` shifts the root application container when the assistant opens so the entire interface slides left and keeps room for the chat. On mobile devices (< 768px), the panel occupies the full screen instead of shifting content. -* `AiAssistantPanel` anchors a full-height chat interface to the right quarter of the viewport as a fixed workspace on desktop, or full-screen on mobile, rendering conversation history and the compose form outside of the main layout. Users can send messages by clicking Send or pressing Ctrl+Enter (Cmd+Enter on Mac). -* Conversation history is stored client-side for rendering while the server keeps the authoritative in-memory copy. +Every model service is guarded by the token `ai-assistant-`. The app +that owns the service declares the token through `ctx.accessRight()`; the router +enforces it on every agent route. -**See [Mobile Layout Documentation](./AiAssistant-Mobile.md) for details on responsive behavior.** +Skills and links carry their own optional `accessRightsToken`, and the built-in +data skills additionally re-check `read--model` / `update--model` +on every call. See [Agent Skills](AiAssistant/AgentSkills.md#permissions). -## Extending With New Models +## Routes -To register a new model: +Registered by the core router under `config.routePrefix`: -1. Create a class that extends `AbstractAiModelService` and implement `generateReply`. -2. Add a factory entry to `modelFactories` in `bindAiAssistant.ts`. -3. Reference the new model identifier in `aiAssistant.models` within your configuration. -4. Assign the generated access token (`ai-assistant-`) to the user groups that should have access. +| Method | Route | Purpose | +|---|---|---| +| `GET` | `/api/ai-assistant/agent/:modelId/status` | Connection state plus the UI schema (hints, commands, panels, `uiMethods`). | +| `GET` | `/api/ai-assistant/agent/:modelId/meta` | Session meta: current model, context usage, turns, model choices. | +| `GET` | `/api/ai-assistant/agent/:modelId/limits` | Provider budget / rate limits, when exposed. | +| `POST` | `/api/ai-assistant/agent/:modelId/model` | Switch the LLM inside the service. | +| `GET` | `/api/ai-assistant/agent/:modelId/history` | Messages of the active conversation. | +| `GET`/`POST` | `/api/ai-assistant/agent/:modelId/conversations` | List / create conversations. | +| `POST` | `/api/ai-assistant/agent/:modelId/conversations/:id/select` | Switch the active conversation. | +| `DELETE` | `/api/ai-assistant/agent/:modelId/conversations/:id` | Delete a conversation. | +| `POST` | `/api/ai-assistant/agent/:modelId/runs` | Start a run (prompt and/or attachments). | +| `GET` | `/api/ai-assistant/agent/:modelId/runs/:runId/stream` | SSE stream of the run. | -## Available Models +`GET /api/ai-assistant/models` (list of models available to the current user) is +registered by the app, not the core router — see `AiAssistantController`. -### Dummy Model (`dummy`) +## Registering an assistant from an app -A simple development model that always returns `"Ai-assistant dummy in development"`. Useful for testing the UI without external dependencies. - -### Simple OpenAI Agent (`openai`) - -A JSON-based command executor that expects structured instructions: - -```json -{ - "action": "create", - "entity": "Example", - "data": { - "title": "Hello from the agent" - } -} -``` - -This model uses `DataAccessor` for permission checks but requires manual JSON formatting. It's useful for scripted operations but not conversational. - -### OpenAI Data Agent (`openai-data`) - -**Recommended for production use.** A conversational AI agent powered by OpenAI's Agents API that: - -* Answers questions in natural language -* Queries database models automatically using tools -* Respects user permissions through `DataAccessor` -* Maintains conversation context -* Handles complex multi-turn conversations - -Example queries: -- "Show me all categories" -- "How many examples are in the database?" -- "What are the recent test records?" - -## OpenAI Data Agent Setup (fixture) - -The fixture ships with an `openai-data` model that demonstrates how to integrate the SDK with -OpenAI's Agents API while still relying on Adminizer's abstractions: - -* The agent implementation lives in `fixture/helpers/ai/OpenAiDataAgentService.ts` and extends - `AbstractAiModelService`. -* Database reads are performed through `DataAccessor`, which means the usual access control and field - sanitisation rules are enforced automatically. -* Conversation history is converted into the `@openai/agents` protocol so follow-up questions can - build on previous answers. - -To enable the agent locally, set the following environment variables before starting the fixture: - -```bash -export OPENAI_API_KEY="sk-..." # required -export OPENAI_AGENT_MODEL="gpt-4.1-mini" # optional override -``` - -`ADMINIZER_OPENAI_KEY` can be used as an alternative variable name for the API key. When a key is -available the fixture automatically registers the model, exposes it in the assistant model list, and -prefers it as the default chat model. If the key is missing the agent stays disabled and a warning is -logged during boot. - -### Configuration - -In your `adminizerConfig.ts`: - -```typescript -aiAssistant: { - enabled: true, - defaultModel: 'openai-data', // Use conversational AI - models: ['openai-data', 'dummy'], +```ts +setup(ctx: AppSetupContext): void { + ctx.accessRight({ + id: "ai-assistant-openharness", + name: "OpenHarness data explorer", + description: "Access to the OpenHarness agent", + department: "AI Assistant", + }); + + ctx.aiAssistant({ + models: [(context) => new OpenHarnessDataAgentService(context)], + }); + + ctx.config({ + aiAssistant: {enabled: true, models: ["openharness"], defaultModel: "openharness"}, + }); + + ctx.controller({ + id: "models", + method: "get", + route: "/api/ai-assistant/models", + middleware: AiAssistantController.getModels, + policies: [{type: "auth", mode: "api"}], + }); } ``` -**Note:** Use `'openai-data'` for natural language conversations, not `'openai'` (which is JSON-only). +Disabling the app removes its routes, config layer, access tokens, assistant +handler, skills, UI methods and admin links. + +The reference implementation is `fixture/apps/ai-assistant/AiAssistantApp.ts`, +registered in `fixture/index.ts`. + +## Fixture agents + +| Id | Class | Notes | +|---|---|---| +| `dummy` | `DummyAiModelService` | No external dependencies; useful for checking the UI and lifecycle. | +| `openai` | `OpenAiModelService` | JSON command executor; scripted experiments only. | +| `openai-data` | `OpenAiDataAgentService` | Conversational agent on the OpenAI Agents API. Needs `OPENAI_API_KEY` (or `ADMINIZER_OPENAI_KEY`). | +| `openharness` | `OpenHarnessDataAgentService` | Streaming agent on `@openharness/core` + AI SDK. Full reference for skills, links, prompts and connection screens. Needs `OPENHARNESS_API_KEY` or `PROJECT_NAME`. | + +A model whose `enabled()` check fails is skipped with a warning, and the app +falls back to `dummy`. + +## Frontend behaviour + +* The panel bundle (`src/assets/js/ai-assistant/agent`) renders the thread, tool + calls, reasoning, attachments, the model picker and the connection screens. +* Everything the panel renders comes from the declarative UI schema returned by + `getUiSchema(locale)` — the panel has no per-agent code. +* `ui.method` frames published by the agent are executed in the browser; only + the built-in `navigate` and `search-admin-links` actions have executors, and + `navigate` goes through the Inertia router published by the main bundle. +* Links written by the LLM in plain text are normalized with + `resolveAdminHref()`: a guessed origin is dropped and a missing route prefix + is restored, so a model that writes `http://localhost/model/Test` still opens + the right admin page. diff --git a/docs/AiAssistant/AdminLinksAndUiMethods.md b/docs/AiAssistant/AdminLinksAndUiMethods.md new file mode 100644 index 00000000..ed7c2842 --- /dev/null +++ b/docs/AiAssistant/AdminLinksAndUiMethods.md @@ -0,0 +1,222 @@ +# Admin Links & UI Methods + +Two registries let an agent work with the panel itself rather than only with +data: + +* **`AdminLinkHandler`** (`adminizer.adminLinkHandler`) — standalone admin pages + and *link templates*, i.e. parametrized pages such as `/model/Test/edit/:id`. +* **`AiAssistantUiMethodHandler`** (`adminizer.aiAssistantUiMethodHandler`) — + browser capabilities the agent may trigger through a `ui.method` frame. + +Both are permission-scoped: an agent can only see and open what its user could +reach by clicking. + +## One registry, four consumers + +`AdminLinkHandler` is the single source of truth for "where am I / where may I +go". Everything that answers that question reads it, so there is never a second +list of pages to keep in sync: + +| Consumer | Entry point | +|---|---| +| Sidebar | `listAccessibleMenuItems()` — the menu the panel renders. | +| Agent | `AiAssistantUiMethodHandler.searchAdminLinks()`, a thin delegate to `AdminLinkHandler.search(user, query)`. | +| Breadcrumbs | `AdminLinkHandler.locate(user, url)` — the chain of registry pages whose path prefixes the url. Served as a lazy shared prop when `navbar.breadcrumbs` is `'auto'`. | +| Global search palette | `GET {routePrefix}/api/links/search?q=` → `search()`. | + +`search()` returns concrete links and templates; `locate()` returns +`{title, href?}` crumbs, shortest first, with no `href` on the page itself. Both +drop anything the user may not open, so their results are safe to hand to an +agent or to the browser. + +## Why templates + +A concrete record page has no fixed URL — it only exists once the id is known. +So navigation is exposed in two shapes: + +| Shape | Example | How the agent uses it | +|---|---|---| +| Link | `/admin/model/Test` | Pass as `href`. | +| Template | `/admin/model/Test/edit/:id` | Pass the template id plus `params: {id: "…"}`. | + +`listTemplates(user)` returns everything parametrized the user may open: + +* `model--edit` and `model--add` for every configured model, + gated by `update--model` / `create--model`. A model hidden from + the user's navbar (`navbar.visible: false`, or `navbar.groupsAccessRights` + naming groups the user is not in) gets neither — the menu is the single rule + for what the registry, and therefore search, the assistant and breadcrumbs, + may show; +* `catalog--item` for every catalog, gated by `catalog-`; +* `link-` for any navigation entry or registered admin link whose own + `link` carries `:placeholders`; +* templates registered by apps. + +Paths matching `/model//remove` are excluded everywhere: opening one +mutates data, so it is never navigable and `isDestructivePath()` rejects it. + +## Built-in UI methods + +| Id | Action | Purpose | +|---|---|---| +| `search-admin-links` | `search-admin-links` | Search the user's accessible sections, links and templates. | +| `navigate` | `navigate` | Open a page in the current tab through the Inertia router. | + +They are advertised to the panel in the UI schema (`schema.uiMethods` on +`GET …/status`) and to the model as tools you define. + +The expected agent flow is: **search first, then navigate** — only to a link or +template the search returned. + +```ts +const searchAdminLinks = tool({ + description: "Search admin pages visible to the current user. Returns concrete links and link" + + " templates: a template has params and is opened by passing its id plus values.", + inputSchema: z.object({query: z.string().min(1)}), + execute: async ({query}) => ({links: this.searchAdminLinks(user, query)}), +}); + +const navigateAdmin = tool({ + description: "Open an admin page in the user's browser tab. Pass href for a concrete link, or" + + " template plus params for a record page returned by search_admin_links.", + inputSchema: z.object({ + href: z.string().min(1).optional(), + template: z.string().min(1).optional(), + params: z.record(z.string(), z.string()).optional(), + }), + execute: async (input) => { + if (!session?.__openharnessPublish) { + throw new Error("Navigation is available only during an active assistant response."); + } + return {opened: this.openAdminLink(input, user, session.__openharnessPublish)}; + }, +}); +``` + +Navigation needs the run's `publish` callback, which only exists during an +active turn — keep it on the session and fail explicitly when it is missing. + +Note that the tool names visible to the model (`search_admin_links`, +`navigate_admin`) are yours to choose; the UI method ids +(`search-admin-links`, `navigate`) are the registry keys. + +## `openAdminLink()` + +`openAdminLink(target, user, publish)` does three things: resolves the target +against the user's permissions, publishes the `navigate` frame, and returns the +resolved URL so the agent can report it as the tool result. + +`resolveNavigation()` rejects: + +* a target with neither `href` nor `template`; +* an `href` outside `config.routePrefix`; +* an `href` that still contains `:placeholders` (must be passed as a template); +* a destructive path; +* a template the user may not open, or one with a missing parameter. + +Template values are URL-encoded, so `{id: "a b/c"}` becomes +`/admin/model/Test/edit/a%20b%2Fc`. + +## Registering pages from an app + +```ts +setup(ctx: AppSetupContext): void { + // A standalone page: appears in navigation and in agent search. + ctx.adminLink({ + id: "reports:daily", // required by the type, but always re-derived as `:` + type: "reports", + name: "daily", + title: "Daily report", + link: "/admin/reports/daily", + section: "Reports", + accessRightsToken: "reports-read", + }); + + // A parametrized page the assistant may open. + ctx.adminLinkTemplate({ + id: "order-invoice", + title: "Order: open invoice", + template: "/admin/orders/:id/invoice", + description: "Open the printable invoice of a single order by its id.", + section: "Sales", + accessRightsToken: "orders-read", + params: [{name: "id", description: "Identifier of the order."}], + }); +} +``` + +* `link` / `template` must be absolute admin paths starting with `/`. +* Placeholders absent from `params` are still required — `params` only adds + descriptions for the model. +* Ids must be unique; re-registering throws. +* Disabling the app removes both. + +Admin links also show up in `listAccessibleMenuItems()`, so they are part of the +sidebar the user sees, not a parallel list. + +## Registering a UI method from an app + +```ts +ctx.skills.uiMethod({ + id: "open-media-preview", + title: "Open media preview", + description: "Open the media manager preview of a file by its id.", + inputSchema: { + type: "object", + properties: {fileId: {type: "string"}}, + required: ["fileId"], + additionalProperties: false, + }, + action: "open-media-preview", + accessRightsToken: "media-manager", +}); +``` + +The registry and its permission check stay server-side, so an LLM cannot invoke +an arbitrary client event. The shared panel only has executors for the two +built-in actions; a custom `action` is ignored until the app's own frontend +bundle registers an executor for it. + +## Client-side behaviour + +`executeUiMethod()` in the panel runtime handles the incoming frames: + +* `navigate` → `resolveAdminHref(input.href)` then a visit through + `window.InertiajsReact.router`, falling back to a full page load. +* `search-admin-links` → dispatches the browser event + `adminizer:ai-search-admin-links` with the input as `detail`, handled by the + panel's global search, which opens with the query prefilled. + +### `resolveAdminHref()` + +Agents — and the plain text an LLM writes — routinely guess an origin, drop the +dev port or omit the route prefix. `resolveAdminHref()` trusts only the path: + +* `mailto:`, `tel:`, `data:` and `#anchors` are left untouched (`internal: false`); +* an absolute URL on another host is left untouched unless its path is inside + the route prefix; +* otherwise the path is resolved against the current origin and a missing route + prefix is restored. + +This is why an agent should write paths exactly as a tool returned them +(`/admin/model/Test/edit/`) and never build a full URL with a host. + +## Prompting + +Navigation only works if the model is told the flow. The fixture prompt +(`fixture/apps/ai-assistant/prompts/openharness.txt`): + +```text +Use list_admin_navigation when the user asks what the panel offers or where something lives: +it returns their own menu, permissions applied. +Use search_admin_links before navigate_admin. Navigate only to a concrete link or template +returned by that search. +When you mention a page in your answer, write its path exactly as the tool returned it +(e.g. /admin/model/Test/edit/); never build a full URL with a host. +``` + +## Testing + +`test/admin-link-templates.test.ts` covers template listing, permission +filtering, encoding and rejection rules against a minimal Adminizer stand-in; +`test/assistant-links-client.test.ts` covers `resolveAdminHref()`. diff --git a/docs/AiAssistant/AgentSkills.md b/docs/AiAssistant/AgentSkills.md new file mode 100644 index 00000000..cfac099c --- /dev/null +++ b/docs/AiAssistant/AgentSkills.md @@ -0,0 +1,183 @@ +# Agent Skills + +A **skill** is a server-side tool an agent may call. Skills are +provider-neutral: a skill is a JSON Schema plus an `execute` callback, so the +same definition works with the AI SDK, the OpenAI Agents API or any other +runtime. Every call is executed on behalf of the authenticated admin user and +re-checks that user's permissions. + +Skills live in `AiAssistantAgentSkillHandler`, always available as +`adminizer.aiAssistantAgentSkillHandler`. + +## Built-in skills + +Registered for every agent, no configuration needed: + +| Id | Purpose | +|---|---| +| `current_user` | Login, name, email, locale, groups and administrator flag of the user this conversation belongs to. | +| `list_admin_navigation` | The user's own admin menu grouped by section, plus the templates of parametrized pages. Accepts an optional `query`. | +| `list_data_models` | Models the user may read or edit, with fields, allowed operations, list URL and record URL template. | +| `read_model_records` | Read records of a readable model. Accepts `filter` (JSON object), `fields` and `limit` (max 50, default 10). | +| `update_model_record` | Update one record of an editable model by its identifier. | + +Because every call re-checks permissions, an agent built on these skills can be +offered to non-administrator accounts as well. + +`read_model_records` and `update_model_record` go through `DataAccessor`, so +field-level restrictions apply: fields the user may not edit are dropped by the +panel, not by the agent. + +## Using skills inside an agent + +```ts +const skills = this.getAgentSkills(user); + +const skillTools = Object.fromEntries(skills.map((skill) => [skill.id, tool({ + description: skill.description, + inputSchema: jsonSchema(skill.inputSchema), + execute: async (input: Record) => this.executeAgentSkill(skill.id, input, user), +})])); + +const agent = new Agent({ + name: this.name, + model: provider.chat(choice.model), + instructions: systemPrompt, + tools: {...skillTools, /* your own tools */}, +}); +``` + +* `getAgentSkills(user)` returns descriptors (`id`, `description`, + `inputSchema`, `accessRightsToken`) — no `execute`, so the descriptor is safe + to serialize. +* `executeAgentSkill(id, input, user)` performs the permission check and passes + the result through `toJsonSafe()`. + +Build the tool set per turn, not once per process: descriptions and schemas are +refined per user. + +## Adding a skill from an app + +```ts +setup(ctx: AppSetupContext): void { + ctx.skills.agent({ + id: "reload_price_list", + description: "Reload the price list from the ERP and report how many positions changed.", + inputSchema: { + type: "object", + properties: { + dryRun: {type: "boolean", description: "Only report what would change."}, + }, + additionalProperties: false, + }, + accessRightsToken: "price-list-reload", + execute: async (input, {user, userIdentity, runtime}) => { + const result = await reloadPrices(runtime, Boolean(input.dryRun)); + return {changed: result.changed, requestedBy: userIdentity.login}; + }, + }); +} +``` + +Rules enforced on registration: + +* `id` must match `^[a-z][a-z0-9_-]*$` and be unique across all skills. +* A non-empty `description` is required — it is the only thing the model sees. +* Registration is deferred until the app's setup phase completes, and disabling + the app removes the skill. + +The `execute` context gives you: + +| Field | Meaning | +|---|---| +| `user` | The full `User` record — pass it to `DataAccessor` for permission-aware data access. | +| `userIdentity` | JSON-safe descriptor of the same user (`id`, `login`, `fullName`, `email`, `locale`, `isAdministrator`, `groups`). | +| `runtime` | The `AppRuntime` of the app that owns the skill. | + +## Permissions + +Three independent layers: + +1. **`accessRightsToken`** on the skill. A skill whose token the user lacks is + neither described nor executable — the model never learns it exists. +2. **Per-call checks inside `execute`.** The built-in data skills resolve + `read--model` / `update--model` on every call and throw when + the user is not allowed, so a stale schema cannot be exploited. +3. **`DataAccessor`.** Field-level and user-owned-record restrictions are + applied by the panel's own data layer. + +### Per-user descriptions + +`describe(user, adminizer)` refines what a particular user is told about a +skill. The built-in data skills use it to inject an `enum` of the models that +user may touch, so the model cannot invent a model name: + +```ts +describe: (user) => { + const models = modelNames(adminizer, user, "read"); + return { + description: `Read records of a data model the current user may read. Readable models: ${models.join(", ") || "none"}.`, + inputSchema: withModelEnum(readRecords.inputSchema, models), + }; +}, +``` + +### `requiresUser` + +Setting `requiresUser: true` advertises the calling user inside the input +schema: a required `currentUser` property whose `enum` is the single +authenticated login. The agent then sees who it acts for and echoes it back. + +Whatever the model passes is **discarded** before `execute` runs and replaced +with the authenticated identity, so this cannot be used to impersonate — it is a +prompt-grounding device, not an authorization input. + +## JSON-safe results + +A tool result is stored verbatim in the agent's message history and re-validated +against the runtime's `ModelMessage[]` schema on every later turn. A single +non-JSON value — a `Date` on a Sequelize record, a `Buffer`, a cyclic relation — +does not fail only the call that produced it: it poisons the session, and every +subsequent turn dies with +`Invalid prompt: The messages do not match the ModelMessage[] schema`. + +`toJsonSafe()` (exported from the package) converts arbitrary values into plain +JSON data: + +* `Date` → ISO string, `Error` → message, `RegExp` → source, `BigInt` → string; +* `Buffer` / typed arrays / `ArrayBuffer` → ``; +* `Set` → array, `Map` → object, `toJSON()` honoured (Sequelize, Decimal, Luxon); +* cycles → `[Circular]`, depth over 12 → `[MaxDepth]`; +* non-finite numbers → `null`; `undefined` / functions / symbols dropped. + +`executeAgentSkill()` applies it automatically. Apply it yourself for any other +tool result you produce, and repair sessions that MCP servers or subagents write +into: + +```ts +session.messages = toJsonSafe(session.messages ?? []); +``` + +## Direct registration (host application) + +An app is the recommended owner, but a host application can register skills +directly: + +```ts +adminizer.aiAssistantAgentSkillHandler.add({ + id: "ping", + description: "Health check.", + inputSchema: {type: "object", properties: {}, additionalProperties: false}, + execute: () => ({ok: true}), +}); +``` + +Skills registered without an owner default to `host` and are never removed +automatically. + +## Testing + +`test/ai-assistant-skills.test.ts` shows the pattern: build a minimal Adminizer +stand-in (config, `accessRightsHelper`, `modelHandler`, `menuHelper`, +`catalogHandler`), instantiate `AiAssistantAgentSkillHandler` and assert that a +user without a token sees neither the skill nor the model behind it. diff --git a/docs/AiAssistant/BuildingAgents.md b/docs/AiAssistant/BuildingAgents.md new file mode 100644 index 00000000..5f4a01dd --- /dev/null +++ b/docs/AiAssistant/BuildingAgents.md @@ -0,0 +1,284 @@ +# Building Agents + +An agent is a class extending `AbstractAiModelService`. The app that owns it +registers it through `ctx.aiAssistant({models: [...]})`; everything the panel +shows is derived from what the class implements. + +There are two levels of capability: + +1. **Plain chat model** — implement `generateReply()` only. The panel runs it + through the same streaming transport and renders the answer as one text part. +2. **Streaming agent** — also implement `streamReply()`. The panel then renders + tool calls, reasoning, attachments, a server-side per-user session, model + switching, conversations and connection screens. Whatever you do not + implement is simply not rendered, so partial implementations are fine. + +## Minimal agent + +```ts +import {AbstractAiModelService} from "adminizer"; +import type {AiAssistantMessage} from "adminizer"; +import type {User} from "adminizer"; + +export class MyAgentService extends AbstractAiModelService { + constructor() { + super({ + id: "my-agent", + name: "My agent", + description: "Answers questions about this panel.", + }); + } + + public async generateReply(prompt: string, history: AiAssistantMessage[], user: User): Promise { + return `You said: ${prompt}`; + } +} +``` + +Register it and its access token from the app: + +```ts +ctx.accessRight({ + id: "ai-assistant-my-agent", + name: "My agent", + description: "Access to My agent", + department: "AI Assistant", +}); +ctx.aiAssistant({models: [() => new MyAgentService()]}); +``` + +`attachAdminizer()` is called on registration, so `this.adminizer` and every +protected helper below are available from the first call onwards — never in the +constructor. + +## Streaming a run + +```ts +public async streamReply( + input: string | Array>, + user: User, + publish: AiAgentPublish, +): Promise { + const session = await this.getSession(user); + for await (const event of session.send(input)) publish(event as AiAgentStreamEvent); +} +``` + +`input` is the plain prompt, or an AI-SDK-style `ModelMessage[]` when the user +attached files. The closing `done` frame is emitted by the run manager, not by +the service. + +Frame types the panel understands: + +| `type` | Meaning | +|---|---| +| `text.delta` / `text.done` | Assistant text. | +| `reasoning.delta` / `reasoning.done` | Reasoning block. | +| `tool.start` / `tool.done` / `tool.error` | A tool call and its result. | +| `ui.method` | Ask the browser to perform a registered UI method — see [Admin Links & UI Methods](AdminLinksAndUiMethods.md). | +| `step.done` / `turn.done` | Carry `usage` for the token counter. | +| `error` | Turn failed. | + +## Optional agent members + +Implement only what your runtime supports. + +| Member | Effect | +|---|---| +| `getSessionMeta(user)` | Model, context window, turn count, context tokens, total usage in the panel header. | +| `getSessionHistory(user)` | Session as `ModelMessage[]`; the panel rebuilds the thread from it. | +| `restoreSessionHistory(user, messages)` | Load a stored dialog when the user switches conversations. | +| `resetSession(user)` | Drop the session; returns whether there was anything to drop. | +| `compactSession(user)` | Summarize/prune the session. Its presence auto-registers the `/compact` command. | +| `getModelChoices()` / `setCurrentModel(user, model)` | Both present ⇒ the panel shows the model picker. | +| `saveUploadedFiles(user, files)` | Persist uploads before the turn, so tools can refer to them by id. | +| `getConnectionStatus()` | Provisioning state. Present and not `ready` ⇒ the panel shows a connection screen instead of the composer. | +| `getLimits(forceRefresh)` | Present ⇒ the panel shows the limits panel. | +| `getUiHints(locale)` | Panel copy for this service. | + +## UI schema + +`getUiSchema(locale)` is what the panel consumes. The base implementation merges +`getUiHints(locale)` with the registered commands and the derived `panels` +flags; `AiAgentController` adds the user's `uiMethods` on top. Override it only +if you need something the hints cannot express. + +```ts +public getUiHints(locale?: string): AiAgentUiHints { + return { + title: this.name, + welcomeHint: "Ask about the data available to your account.", + composerPlaceholder: "Ask about your data… type / for commands", + suggestions: ["What can you do?"], + }; +} +``` + +The locale comes from the admin user, so an agent can localize its own copy. +The panel runs strings through its own dictionary first, so English source +strings it already knows are still translated. + +## Slash commands + +```ts +this.registerChatCommand( + {id: "summarize", description: "Summarize the conversation."}, + async ({arguments: args, user, publish}) => ({text: await this.summarize(user, args)}), +); +``` + +The panel only displays commands and sends the raw `/command arguments` text +back — command handling never lives in the browser. A handler should **return** +text rather than publishing a text frame itself, so the response is persisted in +the conversation. Returning nothing lets the command fall through to +`streamReply`/`generateReply` as normal input. Set `persist: false` for +ephemeral commands that must not enter history. + +If `compactSession()` exists and no `compact` command was registered, `/compact` +is added automatically. + +## Connection screens + +Agents that need provisioning report it through `getConnectionStatus()` +(`registering`, `waiting_retry`, `setup_required`, `error`, `ready`) and describe +each screen declaratively through `ui.connectionScreens`: + +```ts +public getUiHints(locale?: string): AiAgentUiHints { + return { + connectionScreens: { + registering: { + title: "Connecting the assistant…", + description: "The assistant registers itself with the LLM gateway.", + icon: "spinner", + }, + waiting_retry: { + title: "Waiting for the next registration slot", + icon: "spinner", + details: [ + {field: "nextAttemptAt", label: "Next attempt at", format: "local-time"}, + {field: "lastError", tone: "error"}, + ], + }, + setup_required: { + title: "Server setup required", + description: "Set the PROJECT_NAME setting to finish the server setup.", + icon: "bot", + action: {label: "PROJECT_NAME", href: "/settings/PROJECT_NAME"}, + }, + }, + }; +} +``` + +* `details[].field` reads a field of the connection status object, so an agent + chooses which status values are worth showing. +* `format: "local-time"` renders an ISO timestamp in the viewer's timezone. +* `action.href` is normalized with `resolveAdminHref()`, so an admin-relative + path is enough. +* A `default` screen covers any state without its own entry. +* Agents that set none of this keep the legacy behaviour driven by + `setupSetting` / `setupUrl`. + +## System prompts in files + +Keeping prompts out of the code makes them reviewable and swappable per model. +The fixture stores them in `fixture/apps/ai-assistant/prompts/*.txt` and composes +`common.txt` + an agent prompt + a per-model prompt, substituting `{{value}}` +placeholders: + +```text +prompts/ + common.txt # safety and tool-use rules shared by every agent + openharness.txt # agent prompt, uses {{active_user}}, {{available_tools}} + openharness-model-default.txt # fallback per-model prompt + openharness-model-genius.txt # picked when the provider model id slugifies to "genius" +``` + +```ts +instructions: loadSystemPrompt(["openharness", this.getModelPromptName(choice.id)], { + active_provider_model: choice.id, + active_user: this.describeUser(user).login, + available_tools: availableTools, +}), +``` + +The per-model file is chosen by slugifying the provider model id and falling +back to `…-model-default.txt` when no file matches. + +## Model choices + +`getModelChoices()` returns the LLMs a user may switch between. Fetching them +from the provider keeps the list honest — an OpenAI-compatible gateway scopes +`GET /models` to the caller's key — but must be cached, because the panel asks +for metadata often: + +```ts +private modelCatalogCache: {fetchedAt: number; models: ModelChoice[]} | null = null; +``` + +The fixture caches for three minutes and drops the cache whenever the connection +changes, so a new key or permission change is picked up without hitting the +proxy on every request. Always keep a fallback choice for the case where the +catalogue request fails. + +## Sessions + +A streaming agent usually keeps one provider session per user. Two rules from +the fixture are worth copying: + +* **Fingerprint the credentials.** Store `${baseUrl}:${apiKey}` on the session + and rebuild it when it changes, so a rotated key never reuses a stale session. +* **Normalize the history.** Tool results are stored verbatim in the message + history and re-validated on every later turn. One `Date` from an ORM record + poisons the whole session, and every subsequent turn dies with + `Invalid prompt: The messages do not match the ModelMessage[] schema`. Pass + results through `toJsonSafe()` — `executeAgentSkill()` already does — and + repair sessions written to by MCP servers or subagents: + +```ts +session.messages = toJsonSafe(session.messages ?? []); +``` + +## Giving the agent tools + +Do not hand-write data tools. Adminizer exposes provider-neutral skills (JSON +Schema + an `execute` callback) that already apply the calling user's +permissions: + +```ts +const skills = this.getAgentSkills(user); +const skillTools = Object.fromEntries(skills.map((skill) => [skill.id, tool({ + description: skill.description, + inputSchema: jsonSchema(skill.inputSchema), + execute: async (input: Record) => this.executeAgentSkill(skill.id, input, user), +})])); +``` + +See [Agent Skills](AgentSkills.md) for the built-ins and for contributing your +own, and [Admin Links & UI Methods](AdminLinksAndUiMethods.md) for navigation. + +## Protected helpers on `AbstractAiModelService` + +| Helper | Purpose | +|---|---| +| `getAgentSkills(user)` | Skill descriptors this user may call, permissions applied. | +| `executeAgentSkill(id, input, user)` | Run a skill and return a JSON-safe result. | +| `describeUser(user)` | Plain identity of the user (login, name, groups, administrator flag). | +| `getUiMethods(user)` | Browser capabilities available to this user. | +| `searchAdminLinks(user, query)` | Server-side implementation of `search-admin-links`. | +| `openAdminLink(target, user, publish)` | Validate a link or template and publish a `navigate` frame; returns the resolved URL. | +| `publishUiMethod(id, input, user, publish)` | Publish any registered UI method after its permission check. | + +## Checklist + +1. Extend `AbstractAiModelService` and implement `generateReply()`. +2. Add `streamReply()` plus whichever optional members your runtime supports. +3. Build tools from `getAgentSkills(user)` instead of writing data access by hand. +4. Return copy through `getUiHints(locale)`; describe provisioning through + `getConnectionStatus()` + `connectionScreens`. +5. Register the model factory and the `ai-assistant-` token from the app. +6. Add the id to `aiAssistant.models`. + +Model services must not register access rights themselves — the app owns tokens +and lifecycle resources through `AppManager`. diff --git a/docs/Authorization.md b/docs/Authorization.md index e69de29b..54eb1fe8 100644 --- a/docs/Authorization.md +++ b/docs/Authorization.md @@ -0,0 +1,35 @@ +# Authorization + +Adminizer authorization is controlled by the `auth` block in `AdminpanelConfig`. + +```ts +const config = { + routePrefix: "/adminizer", + auth: { + enable: true, + captcha: true, + }, + administrator: { + login: "admin", + password: "change-me", + }, + models: {}, +}; +``` + +When `auth.enable` is `true`, Adminizer registers login, logout, and registration routes under the configured `routePrefix`. + +## Administrator bootstrap + +If there is no administrator account, Adminizer can route users to the initial user setup screen. The fixture provides administrator credentials through `ADMIN_LOGIN` and `ADMIN_PASS`, falling back to local defaults in `fixture/adminizerConfig.ts`. + +## Access gate + +Successful login is not the only check. Users also need the `access-to-adminpanel` token unless they are administrators. Model actions are guarded by generated tokens in the form: + +* `create--model` +* `read--model` +* `update--model` +* `delete--model` + +Custom feature permissions can be registered through `adminizer.accessRightsHelper.registerToken(...)`. diff --git a/docs/BuildingModules.md b/docs/BuildingModules.md index 3ddd2229..ce1fc5bc 100644 --- a/docs/BuildingModules.md +++ b/docs/BuildingModules.md @@ -1,373 +1,778 @@ -# Building a Module for Adminizer +# Adminizer App Modules -This guide covers everything you need to create your own React module for an Adminizer-powered project — whether it's a full admin page, a custom field control, or a dashboard widget. +Adminizer modules are backend-first extensions built around `AbstractAdminizerApp` and `AppManager`. A module can register routes, frontend assets, form controls, config patches, access rights, existing ORM models, model access scopes, AI assistants, media managers, catalogs with template components, and event listeners. This is the current extension mechanism for features such as the fixture module pages, AI assistant, media manager, module manager, navigation catalog, and React-Quill control. -**Related docs:** -- [UIComponents.md](UIComponents.md) — full reference for all components available via `window.UIComponents` and `window.JSComponents` -- [adminizer-module.d.ts](../adminizer-module.d.ts) — TypeScript declarations for globals and module contracts +Older standalone React page modules may still exist as frontend patterns, but a full Adminizer module should be installed through `adminizer.appManager`. ---- +## App Boundary -## How the UI layer works +An app must not accept, store, or depend on an `Adminizer` instance. The host application owns Adminizer and uses it only to enable the app: -Adminizer registers React components into `window.UIComponents` at startup via `registerUIComponents()`. Your module consumes them at runtime without bundling its own copy. +```ts +await adminizer.init(adminpanelConfig); +await adminizer.appManager.enable(new MyApp({ + route: "/my-app", +})); +``` -This means: -- consistent look & feel with the core UI; -- no duplicate React / shadcn / Radix in your bundle; -- dark/light theme and spacing handled automatically. +The app constructor accepts only its own configuration and optional app-level dependencies: -See [UIComponents.md](UIComponents.md) for the full list of available components and their props. +```ts +// Correct +new MyApp({ route: "/my-app" }); ---- +// Do not do this +new MyApp(adminizer, { route: "/my-app" }); +``` -## Types of modules +This boundary lets `AppManager` control resource ownership, cleanup, model permissions, and the capabilities exposed to each app. -| Type | Loaded by | Default export receives | -|---|---|---| -| **Page module** | `module.tsx` (full-page Inertia route) | `{ data?: TData }` | -| **Field control** | `DynamicControls` (inside a form field) | `{ initialValue, onChange, name, options }` | +Use the API supplied for each lifecycle phase: ---- +| App code | Access point | +|---|---| +| `setup()` | Register resources through `AppSetupContext`. | +| App controller | Use the app-scoped `req.runtime`. | +| Catalog factory | Use the `runtime` factory argument. | +| Event listener | Use the `runtime` handler argument. | -## 1. Page module +`req.adminizer` is a deprecated compatibility API. It will not be available starting with Adminizer v6 and must not be used in app code. -A page module is a standalone React component rendered as a full admin page. +## Core Types -```tsx -// my-page-module.tsx -import type { PageModuleProps } from 'adminizer/adminizer-module'; - -interface Item { id: number; name: string; status: string } - -export default function MyPageModule({ data }: PageModuleProps<{ items: Item[] }>) { - const { Button, Table, TableHeader, TableBody, TableRow, TableHead, TableCell, - Badge, Toaster } = window.UIComponents; - const { items = [] } = data ?? {}; - const [loading, setLoading] = React.useState(false); - - const handleAction = async (id: number) => { - setLoading(true); - try { - await window.axios.post(`${window.routePrefix}/api/my-resource/${id}/action`); - window.sonner.toast.success('Done'); - } catch { - window.sonner.toast.error('Failed'); - } finally { - setLoading(false); - } - }; +Import module contracts from Adminizer: - return ( - <> - - - - - Name - Status - - - - - {items.map(item => ( - - {item.name} - {item.status} - - - - - ))} - -
- - ); +```ts +import { + AbstractAdminizerApp, + AppSetupContext, + AppRuntime, +} from "adminizer"; +``` + +A module extends `AbstractAdminizerApp`: + +```ts +export class MyApp extends AbstractAdminizerApp { + readonly name = "my-app"; + readonly version = "1.0.0"; + declare readonly config: MyAppConfig; + + constructor(config: Partial = {}) { + super(); + this.config = { + route: "/my-app", + ...config, + }; + } + + setup(ctx: AppSetupContext): void | Promise { + // Register resources here. + } } ``` -**Backend registration (route + Inertia render):** +Enable it after `adminizer.init(config)`: ```ts -// Pass data and JS path to the frontend -res.render('module', { - moduleComponent: `${routePrefix}/modules/my-page/my-page-module.js`, - data: { items: await fetchItems() }, -}); +await adminizer.init(adminpanelConfig); +await adminizer.appManager.enable(new MyApp()); ``` ---- +`AppManager.enable(app)` registers the app if needed, runs `setup(ctx)`, waits for deferred model, model-access, media-manager, and catalog registrations, and stores disposers for later `disable()` or `unregister()`. `setup()` receives only `AppSetupContext`; it does not receive `Adminizer`. -## 2. Field control module +## Resource Registration -A field control replaces the default input for a specific field type (wysiwyg, codeEditor, jsonEditor, etc.). +`setup(ctx)` receives an `AppSetupContext` with these methods: -### 2.1 Frontend component +| Method | Purpose | +|---|---| +| `ctx.asset(asset)` | Expose a JS/CSS/static file and return its URL. In `ADMINIZER_ENV=dev`, `devUrl` is returned directly. In production, Adminizer creates a static controller for `filePath`. | +| `ctx.control(control)` | Register an app-owned form control with its component asset, optional stylesheet asset, type, name, and base config. | +| `ctx.controller(controller)` | Register an Express route under `config.routePrefix`; returns the resolved full path. | +| `ctx.config(patch, id?)` | Merge a config patch into `adminizer.config`. Arrays of objects with `id` are merged by id; other arrays are appended. | +| `ctx.accessRight(token)` | Register an access rights token owned by the app. | +| `ctx.model(model)` | Attach an existing ORM model to the app. The model must be installed before the app is enabled. | +| `ctx.modelAccess(access)` | Allow the app runtime to access selected models through `runtime.models`. | +| `ctx.aiAssistant(resource)` | Register an app-owned AI assistant handler with model factories. | +| `ctx.skills.agent(skill)` | Register a server-side AI assistant skill (a tool every compatible agent may call). See [Agent Skills](AiAssistant/AgentSkills.md). | +| `ctx.skills.uiMethod(method)` | Register a browser capability an agent may trigger through a `ui.method` frame. See [Admin Links & UI Methods](AiAssistant/AdminLinksAndUiMethods.md). | +| `ctx.adminLink(link)` | Register a standalone admin page in the navigation and in agent link search. An optional `badge` (value or per-user resolver) shows a count next to it. | +| `ctx.adminLinkTemplate(template)` | Register a parametrized page (e.g. `/admin/orders/:id/invoice`) the assistant may open. | +| `ctx.context(context)` | Register a layout context: own URL prefix, navbar and, optionally, a layout — inherited with some parts overridden, or a custom one. Returns its scope; `page()` adds a route, a menu item and breadcrumbs in one call. See [Layout Contexts](Layout%20Contexts.md). | +| `ctx.mediaManager(resource)` | Register an app-owned media manager through a factory that receives `AppRuntime`. | +| `ctx.catalog(catalog)` | Register a catalog factory and its optional React template components. | +| `ctx.listener(event, handler)` | Subscribe to Adminizer events. The handler receives `(payload, runtime)`. | -```tsx -// my-editor.tsx -import type { FieldModuleProps } from 'adminizer/adminizer-module'; +Registration order after `setup()` is intentional: app model bindings first, model access second, AI assistants third, assistant UI methods and agent skills fourth, media managers fifth, and catalogs sixth. This lets factories safely use models owned by the same app. -export default function MyEditor({ initialValue, onChange, name, options }: FieldModuleProps) { - const { MonacoEditor } = window.JSComponents; +## App Runtime - return ( - - ); +`AppRuntime` is the public capability object for app code. App controllers receive it through `req.runtime`; media manager factories, catalog factories, and event listeners receive the same app-scoped runtime as an argument. + +```ts +interface AppRuntime { + models: AppModelAccess; + config: { + readonly routePrefix: string; + getModelConfig(modelName: string): ModelConfig | undefined; + }; + notifications: { + send( + notification: Omit + ): Promise; + }; + apps: { + list(): AppRuntimeApp[]; + get(name: string): AppRuntimeApp | undefined; + enable(name: string): Promise; + disable(name: string): Promise; + unregister(name: string): Promise; + }; } ``` -Props received from `DynamicControls`: +Use `runtime.models.get(modelName)` only for models declared through `ctx.modelAccess()`. Pass the canonical Adminizer resource name, not a physical ORM host model name. This keeps internal and app model access explicit. Runtime model access returns a trusted repository and does not apply `DataAccessor` user or field filtering, so user-facing controllers should enforce access through policies and module-level checks. -| Prop | Type | Description | -|---|---|---| -| `initialValue` | `string` | Current field value | -| `onChange` | `(value: string) => void` | Emit new value to the form | -| `name` | `string` | HTML field name attribute | -| `options` | `Record` | Config from `AbstractControls.getConfig()` merged with per-field overrides | +For example, if the project declares `Customer: { model: "User" }`, grant and request `Customer`: -### 2.2 Backend control class +```ts +ctx.modelAccess({ models: ["Customer"] }); +const customers = runtime.models.get("Customer"); +``` ```ts -import { AbstractControls, ControlType, Path, Config, Adminizer } from 'adminizer'; - -export class MyEditor extends AbstractControls { - readonly name = 'my-editor'; - readonly type: ControlType = 'codeEditor'; - readonly path: Path = { - jsPath: { - dev: `${this.routPrefix}/modules/my-editor/my-editor.js`, - production: `${this.routPrefix}/modules/my-editor/my-editor.prod.js`, - }, - cssPath: '', - }; - readonly config: Config = { language: 'javascript' }; +ctx.modelAccess({ models: ["Navigation", "Category"] }); + +ctx.catalog({ + id: "navigation", + factory: async (runtime) => { + const navigationModel = runtime.models.get("Navigation"); + // Create and return AbstractCatalog. + }, +}); +``` - constructor(adminizer: Adminizer) { super(adminizer); } +For controllers, register model access during setup and read the model through `req.runtime`: - getName() { return this.name; } - getConfig() { return this.config; } - getJsPath() { return process.env.VITE_ENV === 'dev' ? this.path.jsPath.dev : this.path.jsPath.production; } - getCssPath() { return this.path.cssPath || undefined; } +```ts +setup(ctx: AppSetupContext): void { + ctx.modelAccess({ + id: "users", + models: ["User"], + }); + + ctx.controller({ + id: "users", + method: "get", + route: "/my-app/users", + middleware: async (req, res) => { + const users = await req.runtime.models.get("User").find({}); + return res.json({ users }); + }, + policies: [{ type: "auth", mode: "api" }], + }); } ``` -Available `type` values: `wysiwyg` | `jsonEditor` | `geoJson` | `markdown` | `table` | `codeEditor` +`ControllerHandler` assigns the runtime for the app that registered the controller before its middleware executes. Do not capture `Adminizer` in a controller closure and do not use `req.adminizer`. -### 2.3 Register and use +## Routes And Policies -**Register on `adminizer:loaded`:** +`ctx.controller()` registers routes through `ControllerHandler`. The route is automatically prefixed with the configured Adminizer route prefix, which is available to the controller as `req.runtime.config.routePrefix`. ```ts -adminizer.emitter.on('adminizer:loaded', () => { - adminizer.controlsHandler.add(new MyEditor(adminizer)); +const pageUrl = ctx.controller({ + id: "page", + method: "get", + route: "/my-app", + middleware: async (req, res) => { + return req.Inertia.render({ + component: "module", + props: { + moduleComponent, + data: { rows: [] }, + }, + }); + }, + policies: [{ type: "auth", mode: "ui" }], }); ``` -**Reference in model field config:** +Supported policy types: + +| Policy | Meaning | +|---|---| +| `{ type: "auth", mode?: "ui" | "api" }` | Require authenticated user. | +| `{ type: "auth-enabled" }` | Require auth only when auth is enabled. | +| `{ type: "admin", mode?: "ui" | "api" }` | Require admin user. | +| `{ type: "permission", token, mode?: "ui" | "api" }` | Require one access token. | +| `{ type: "any-permission", tokens, mode?: "ui" | "api" }` | Require any token from a list. | + +Use `mode: "ui"` for Inertia pages and `mode: "api"` for JSON endpoints. + +`context: ""` declares the layout context the route belongs to: the page renders in that context, and users who may not enter it get 403 (404 if no such context is registered). See [Layout Contexts](Layout%20Contexts.md#pages-declaring-their-context). + +## Frontend Page Module + +Adminizer renders app pages with the shared Inertia page `module`. Your controller passes `moduleComponent`; the page dynamically imports that ES module and renders its default export. + +Props of the `module` page: + +| Prop | Type | Meaning | +|---|---|---| +| `moduleComponent` | `string` | URL of the ES module whose default export is rendered. Required. | +| `moduleComponentCSS` | `string` | URL of a stylesheet injected once as a global ``. See *Styling App Modules*. | +| `breadcrumbs` | `Array<{title: string; href?: string}>` | Shown in the panel header. Omit `href` on the last crumb. Titles are rendered as given — translate them in the controller (`req.i18n.__`). | +| `moduleLayout` | `'default' \| 'bare'` | `'bare'` removes the content margin so the module root becomes a direct child of the scroll container (log viewers, canvases, diff views). Default keeps today's `m-2 md:m-5`. | + +Everything else in `props` is passed to the module component as-is. + +With `navbar.breadcrumbs: 'auto'` pages that send no `breadcrumbs` get them from the registry; +register your deep pages with `ctx.adminLinkTemplate` and they appear in the chain. + +With `moduleLayout: 'bare'` the module root becomes a direct child of +`div.flex.min-h-full.w-full.flex-col`, so padding is yours to decide; add +`className="flex-1 min-h-0"` on the root to fill the scroll area's height. ```ts -fields: { - body: { - title: 'Body', - type: 'codeEditor', - options: { - name: 'my-editor', // matches AbstractControls.name - config: { language: 'sql' } // overrides control defaults - } - } -} +return req.Inertia.render({ + component: "module", + props: { + moduleComponent, + breadcrumbs: [ + {title: "Agentiz", href: `${req.runtime.config.routePrefix}/agentiz`}, + {title: "Projects", href: `${req.runtime.config.routePrefix}/agentiz/projects`}, + {title: project.name}, + ], + moduleLayout: "bare", + data, + }, +}); ``` ---- +```tsx +// MyAppPage.tsx +export default function MyAppPage({ data }: { data?: { rows: Array<{ id: number; name: string }> } }) { + const { Button, Card, CardContent } = window.UIComponents; -## 3. Building with Vite + return ( + + + {data?.rows.map((row) => ( + + ))} + + + ); +} +``` -Your module must be built as an ES module with Adminizer's globals as externals — otherwise React and UI components will be duplicated in the bundle. +Register the built asset and route: ```ts -// vite.config.module.ts -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; -import { viteExternalsPlugin } from 'vite-plugin-externals'; +const moduleComponent = ctx.asset({ + id: "component", + filePath: path.resolve(import.meta.dirname, "MyAppPage.es.js"), + devUrl: "/apps/my-app/MyAppPage.tsx", +}); -export default defineConfig({ - plugins: [ - react(), - viteExternalsPlugin({ - 'react': 'React', - 'react-dom': 'ReactDOM', - 'axios': 'axios', - 'sonner': 'sonner', - }), - ], - build: { - lib: { - entry: 'src/my-module.tsx', - formats: ['es'], - fileName: 'my-module', - }, - rollupOptions: { - // Keep UIComponents and JSComponents external too - // if you import them as bare specifiers in your module +ctx.controller({ + id: "page", + method: "get", + route: "/my-app", + middleware: async (req, res) => req.Inertia.render({ + component: "module", + props: { + moduleComponent, + data: { rows: await loadRows() }, }, + }), + policies: [{ type: "auth", mode: "ui" }], +}); +``` + +Add a sidebar link through a config layer: + +```ts +ctx.config({ + navbar: { + additionalLinks: [{ + id: "my-app", + link: pageUrl, + type: "self", + title: "My App", + icon: "settings", + section: "Platform", + }], }, }); ``` -The output is a small `.js` file that reads globals from the Adminizer page at runtime. +## App Models ---- +A module declares ownership of an ORM model by name: -## 4. TypeScript setup +```ts +ctx.model({ + name: "MyAppState", +}); -Add the Adminizer module declarations to your `tsconfig.json`: +ctx.modelAccess({ models: ["MyAppState"] }); +``` -```json -{ - "compilerOptions": { - "jsx": "react-jsx", - "moduleResolution": "bundler" - }, - "include": [ - "src", - "node_modules/adminizer/adminizer-module.d.ts" - ] -} +`AppManager` does not define tables, register native ORM models, or synchronize schemas. The host or module installer must complete those operations before `appManager.enable()`. + +For Sequelize, models can be installed after `adminizer.init()` and immediately before enabling the app: + +```ts +await installMyAppSequelizeModels(sequelize); +await adminizer.appManager.enable(new MyApp()); ``` -This gives you: -- autocomplete and hover docs for `window.UIComponents.*`, `window.JSComponents.*`, `window.adminApi`, `window.sonner` -- `PageModuleProps` and `FieldModuleProps` types for your component signatures +Disabling an app removes its Adminizer model wrapper but does not remove the native Sequelize model or its table. ---- +`adapter` is optional. If omitted, Adminizer uses `config.system.defaultORM` or the only registered ORM adapter. -## 5. Using UI components +### TypeORM -Destructure from `window.UIComponents` at the top of your component. All components follow the same API as documented in [UIComponents.md](UIComponents.md). +TypeORM support remains experimental. Core logs a warning whenever an app is enabled while `system.defaultORM` is `typeorm`. A TypeORM entity required by an app must be included in `DataSource.entities` before `dataSource.initialize()`. TypeORM does not support the Sequelize-style dynamic app model installation used by the fixture. -```tsx -const { - Button, Badge, Card, CardHeader, CardTitle, CardContent, - Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose, - Input, Label, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, - Table, TableHeader, TableBody, TableRow, TableHead, TableCell, - Toaster, -} = window.UIComponents; +Apps without their own models can still be enabled normally with TypeORM. An app with a model can also be enabled when its entity was registered before initialization. If the entity is missing, `AppManager` fails during `enable()` with an error explaining this requirement. + +## AI Assistant Resources + +An app can own the active AI assistant handler and model services: + +```ts +ctx.accessRight({ + id: "ai-assistant-my-model", + name: "My assistant model", + description: "Access to My assistant model", + department: "AI Assistant", +}); + +ctx.aiAssistant({ + models: [ + (context) => new MyAiModelService(context), + ], +}); -const { MultiSelect, MonacoEditor } = window.JSComponents; -const { Pencil, Trash2, Plus } = window.LucideReact; +ctx.config({ + aiAssistant: { + enabled: true, + defaultModel: "my-model", + models: ["my-model"], + }, +}); ``` -**Key rules:** -- Never `import` shadcn, Radix, or Lucide directly — use the globals above. -- Wrap your root with `` once if you use `window.sonner.toast`. -- Wrap with `` once if you use ``. -- Use `window.adminApi` instead of raw `window.axios` for JSON API calls — it adds no-cache headers and throws a readable error if the session expires. +`ctx.aiAssistant()` creates an `AiAssistantHandler` for the app and registers model services returned by its factories. The factory receives an AI context with `runtime`, `routePrefix`, model resource lookup helpers, permission checks, `createDataAccessor()`, and `getUiMethods(user)`. Disabling the app restores the previous handler, removes the app's config layer, and unregisters app-owned access tokens. + +Assistant model classes should extend `AbstractAiModelService`, but they should not register access rights themselves. The app owns tokens through `ctx.accessRight()`. Register routes with `ctx.controller()` and the reusable `AiAssistantController` when the app exposes the standard assistant API. The fixture implementation is in `fixture/apps/ai-assistant`. ---- +An app can also contribute capabilities to agents it does not own: server-side tools through `ctx.skills.agent()`, browser capabilities through `ctx.skills.uiMethod()`, and navigable pages through `ctx.adminLink()` / `ctx.adminLinkTemplate()`. All four are removed when the app is disabled. See [AI Assistant](AiAssistant.md) for the full contracts. -## 6. HTTP calls +## Media Manager Resources + +An app can own a media manager implementation: ```ts -// Preferred — typed, no-cache, throws on HTML responses (session expiry) -const { data } = await window.adminApi.getJson<{ rows: Row[] }>( - `${window.routePrefix}/api/my-resource` -); +ctx.mediaManager({ + factory: (runtime) => new MyMediaManager(runtime, config), +}); +``` + +The manager is created after the app models and scoped model access are registered. Disabling the app unregisters the manager automatically. The app owns its access right, models, model access, and storage implementation. + +In Adminizer v5, the media manager HTTP API, upload adapter, thumbnail endpoint, public-file binding, and file icons remain in core for legacy compatibility. A manager registered through `mediaManagerHandler.add()` and a manager registered through `ctx.mediaManager()` use the same core routes. This transport layer is planned to move to the system runtime in v6. + +The fixture default implementation is in `fixture/apps/media-manager`. Its Sequelize installer runs after `adminizer.init()` and before `appManager.enable()`. It is intentionally not enabled in the TypeORM fixture: TypeORM remains experimental and app entities must be registered in `DataSource.entities` before `initialize()`. + +`AbstractMediaManager` still accepts `Adminizer` in its constructor and registers its access right through `_bindAccessRight()` for the legacy v5 path. Existing integrations may continue to instantiate a custom manager and call `adminizer.mediaManagerHandler.add(manager)` until v6. App-owned managers should register their access right with `ctx.accessRight()` and pass a no-op legacy host to the base constructor; see the fixture implementation and [MediaManager.md](MediaManager.md). + +## Catalog Template Components + +Catalog item types return add/edit templates with a `type` string: -await window.adminApi.postJson(`${window.routePrefix}/api/my-resource`, { name: 'New' }); -await window.adminApi.putJson(`${window.routePrefix}/api/my-resource/${id}`, payload); -await window.adminApi.deleteJson(`${window.routePrefix}/api/my-resource/${id}`); +```ts +async getAddTemplate(req: ReqType) { + return { + type: "my-catalog.item-form", + data: { labels: { save: req.i18n.__("Save") } }, + }; +} ``` -Always use `window.routePrefix` to build URLs — it holds the configured admin prefix (e.g. `/admin`). +A module maps that template `type` to a React component in the `templates` array of `ctx.catalog()`: ---- +```ts +const catalogTemplates = ctx.asset({ + id: "catalog-templates", + filePath: path.resolve(import.meta.dirname, "CatalogTemplates.es.js"), + devUrl: "/apps/my-app/CatalogTemplates.tsx", +}); -## 7. Notifications (toasts) +ctx.catalog({ + id: "my-catalog", + templates: [{ + id: "item-form", + type: "my-catalog.item-form", + component: catalogTemplates, + exportName: "MyCatalogItemTemplate", + }], + factory: (runtime) => new MyCatalog(runtime), +}); +``` + +Template components are scoped to the catalog returned by the same resource. `CatalogTemplateComponentHandler` prevents two enabled components from claiming the same template type in that catalog scope. + +Template props on the frontend: ```ts -window.sonner.toast('Record saved'); -window.sonner.toast.success('Created successfully'); -window.sonner.toast.error('Something went wrong'); -window.sonner.toast.warning('Deprecated field'); -window.sonner.toast.promise( - window.adminApi.postJson('/admin/api/items', payload), - { loading: 'Saving...', success: 'Saved', error: 'Failed' } -); +interface CatalogTemplateComponentProps { + mode: "create" | "update"; + type: string; + template: { type: string; data: any }; + parentId: string | number; + itemType: string | null; + selectedItem?: Record; + messages: Record; + actions: { + close: () => void; + reload: (item?: any) => Promise; + openModelAdd: (model: string) => Promise; + }; +} ``` ---- +A minimal template: -## 8. Navigation links and the Inertia popup problem +```tsx +export function MyCatalogItemTemplate({ template, itemType, parentId, actions }: CatalogTemplateComponentProps) { + const { Button, Input, Label } = window.UIComponents; + const [name, setName] = React.useState(""); + + const save = async () => { + const res = await window.adminApi.post("", { + _method: "createItem", + data: { + type: itemType, + parentId, + name, + }, + }); + actions.close(); + await actions.reload(res.data.data); + }; -Adminizer uses [Inertia.js](https://inertiajs.com/) for SPA navigation. The sidebar's `` component intercepts clicks and performs an XHR fetch to the target URL expecting an Inertia JSON response. + return ( +
+ + setName(e.target.value)} /> + +
+ ); +} +``` -**If the link target is NOT an Inertia endpoint, a popup modal appears and then the page does a full reload.** +## Catalog Module Pattern -This happens because Inertia receives an HTML page instead of the expected JSON response and falls back to a hard redirect, which briefly shows an error popup. +For catalogs, prefer registering through an app instead of calling `adminizer.catalogHandler.add()` directly. The module can register storage models, access rights, sidebar links, catalog templates, and event listeners in one place. -### When does this occur? +```ts +export class MyCatalogApp extends AbstractAdminizerApp { + readonly name = "my-catalog"; + readonly version = "1.0.0"; + declare readonly config: MyCatalogAppConfig; + + constructor(config: MyCatalogAppConfig) { + super(); + this.config = config; + } -- The link uses a **relative path** that resolves to the wrong URL (e.g. `/integrations` instead of `/dashboard/integrations`). -- The target route **does not call `req.Inertia.render()`** — for example it returns plain JSON or redirects. -- The link points to an **external URL** or a route outside the adminizer prefix. + setup(ctx: AppSetupContext): void { + ctx.accessRight({ + id: "catalog-my-catalog", + name: "My Catalog", + description: "Access to My Catalog", + department: "Catalog", + }); + + ctx.model({ + name: "MyCatalogStorage", + }); + + ctx.modelAccess({ models: ["MyCatalogStorage"] }); + + ctx.config({ + navbar: { + additionalLinks: [{ + id: "my-catalog-main", + type: "self", + link: `${this.config.routePrefix}/catalog/my-catalog/main`, + title: "My Catalog", + icon: "folder", + accessRightsToken: "catalog-my-catalog", + }], + }, + }); + + ctx.catalog({ + id: "my-catalog", + factory: async (runtime) => { + const catalog = new MyCatalog(runtime, this.config); + await catalog.ready?.(); + return catalog; + }, + }); + } +} +``` -### How to fix +## Events -**1. Always use full paths with the `routePrefix`:** +`ctx.listener(event, handler)` is useful for synchronizing module data with model changes. Adminizer emits `model:updated` after edit controller updates a record. ```ts -// ❌ Wrong — resolves to /integrations, not an Inertia route -link: '/integrations' +ctx.listener("model:updated", async (event: { modelName: string; record: Record }, runtime) => { + if (event.modelName.toLowerCase() !== "category") { + return; + } -// ✅ Correct — matches the registered Inertia route -link: '/dashboard/integrations' + const storage = runtime.models.get("MyCatalogStorage"); + await storage.update({ where: { label: "main" } }, { touchedAt: Date.now() } as any); +}); ``` -**2. For hash-based navigation (SPA sub-pages), use `` instead of Inertia ``:** +`AppManager` also emits lifecycle events such as `app:registered`, `app:enable:start`, `app:enabled`, `app:disable:start`, `app:disabled`, `app:unregistered`, and resource-level registration events. + +## Vite Build For App Components -Inertia's `` does not understand hash-only URLs. If you navigate to `/dashboard/integrations#edit/123`, Inertia will fetch `/dashboard/integrations#edit/123` as a new page request, ignoring the hash. +Build module UI as an ES module and externalize Adminizer globals to avoid bundling another copy of React or UI components. -Use a native `` tag for URLs that contain a `#`: +```ts +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import path from "path"; +import { viteExternalsPlugin } from "vite-plugin-externals"; + +export default defineConfig({ + plugins: [ + react(), + viteExternalsPlugin({ + react: "React", + "react-dom": "ReactDOM", + "lucide-react": "LucideReact", + sonner: "sonner", + "@/components/ui/button": "UIComponents", + "@/components/ui/card": "UIComponents", + "@/components/ui/input": "UIComponents", + "@/components/ui/label": "UIComponents", + }), + ], + build: { + outDir: path.resolve(import.meta.dirname, ""), + emptyOutDir: false, + lib: { + entry: path.resolve(import.meta.dirname, "MyAppPage"), + formats: ["es"], + fileName: (format) => `MyAppPage.${format}.js`, + }, + rollupOptions: { + external: [ + "@/components/ui/button", + "@/components/ui/card", + "@/components/ui/input", + "@/components/ui/label", + ], + }, + }, + resolve: { + alias: { + "@": path.resolve(import.meta.dirname, "../../src/assets/js"), + }, + }, +}); +``` + +For heavy built-in `window.JSComponents` controls, import Adminizer's lazy proxy modules in development and externalize those imports in production: ```tsx -function NavLink({ href, children }: { href: string; children: React.ReactNode }) { - if (href.includes('#')) return {children}; // full reload, keeps hash - return {children}; // Inertia SPA navigation +import HandsonTable from "@/js-components/handsontable"; +import MonacoEditor from "@/js-components/monaco"; +import VanillaJSONEditor from "@/js-components/jsoneditor"; +``` + +```ts +viteExternalsPlugin({ + "@/js-components/handsontable": ["JSComponents", "HandsonTable"], + "@/js-components/monaco": ["JSComponents", "MonacoEditor"], + "@/js-components/jsoneditor": ["JSComponents", "VanillaJSONEditor"], +}); +``` + +Do not import `@/components/handsontable`, `@/components/monaco-editor`, or `@/components/VanillaJSONEditor` directly from app modules when you want lazy loading. Those component imports bypass the control proxy layer that loads the production assets and related styles on demand. + +Use project-specific build scripts for modules. The fixture uses: + +```bash +npm run build:apps +``` + +`build:apps` also runs `build:catalog-modules`, which builds the legacy virtual catalog React templates and actions from `fixture/virtual-catalog`. + +## Styling App Modules + +Adminizer's stylesheet is compiled from Adminizer's own sources. An app module is **not** part of +that content scan, so Tailwind never generates a class only your module uses. Standard utilities the +panel already uses (`flex`, `rounded`, `p-2`, `text-sm`, `bg-muted`, `text-destructive`, `size-6`) +are present and work. Anything else silently does nothing — most often: + +- arbitrary values: `text-[11px]`, `min-w-[220px]`, `size-[18px]`; +- utilities the panel happens not to use: `w-16`, `max-w-40`, `border-amber-300`. + +Nothing errors; the element simply renders unstyled, which is easy to miss in review. Check a class +against the shipped stylesheet before relying on it: + +```bash +grep -c -- "\.w-16" node_modules/adminizer/assets/app-*.css # 0 = not generated +``` + +Three ways to style a module, in order of cost: + +1. **Stay inside the panel's utilities.** Best default: the module looks native and ships no CSS. +2. **Inline styles for one-off geometry.** `style={{ width: "4rem" }}` always applies and needs no + build step — the right tool for a handful of sizes. +3. **Ship the module's own stylesheet** when it has real styling of its own (a canvas, a chart, a + vendored widget). Adminizer supports this directly: pass `moduleComponentCSS` next to + `moduleComponent` and the module page injects a `` for it, once per path. + +### Shipping a module stylesheet + +Author a CSS entry that pulls **utilities only**. Importing `tailwindcss` wholesale would also bring +Preflight and reset the panel around your module: + +```css +/* MyAppPage.css */ + +/* Theme namespaces (spacing, typography, radii, shadows, animations…). Without this + import Tailwind v4 has no scales, and every utility that reads one — p-4, text-sm, + rounded-md, gap-2 — is silently not generated. `theme(reference)` makes the namespaces + available to the compiler but emits NO custom properties, so this file cannot + perturb the panel's palette; utilities compile with a fallback baked in, e.g. + `font-size: var(--text-sm, 0.875rem)`. */ +@import "tailwindcss/theme.css" layer(theme) theme(reference); + +/* Utilities only. Importing `tailwindcss` wholesale would also bring Preflight and + reset the panel around your module. */ +@import "tailwindcss/utilities.css" layer(utilities); + +/* Tell Tailwind what to scan — the module's own sources, nothing else. */ +@source "./**/*.tsx"; + +/* Map the theme to the panel's CSS variables, so tokens follow light/dark with the panel + instead of resolving to Tailwind's default palette. Adminizer defines these on :root. */ +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-warning: var(--warning); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + /* Radii: Tailwind reads --radius-sm|md|lg|xl, never --radius itself. Map them the way + Adminizer's own app.css does. NEVER write `--radius: var(--radius)` here — it compiles + to a self-referencing property on :root and squares every corner of the whole panel. */ + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); } ``` -**3. Ensure every custom route renders via Inertia:** +Import it from the entry component and let Vite emit it beside the bundle: + +```tsx +// MyAppPage.tsx +import "./MyAppPage.css"; +``` ```ts -// ✅ Required — Inertia.render() returns the correct JSON envelope -handler: async (req, res) => { - return req.Inertia.render({ component: 'module', props: { ... } }); -} +// vite.config.ts — alongside the module build shown above +import tailwindcss from "@tailwindcss/vite"; -// ❌ Wrong — plain res.json() triggers the popup -handler: async (req, res) => { - return res.json({ data: items }); -} +export default defineConfig({ + plugins: [react(), tailwindcss()], + build: { + cssCodeSplit: true, // emits MyAppPage.css next to MyAppPage.es.js + lib: { entry: "MyAppPage", formats: ["es"], fileName: (f) => `MyAppPage.${f}.js` }, + }, +}); ``` -Note: API routes that return JSON (e.g. `?_method=getItems`) must be handled **before** the Inertia render branch in the same handler, not as separate routes, to avoid Inertia intercepting them. +Register the stylesheet as an asset and pass its URL with the page: ---- +```ts +const moduleComponentCSS = ctx.asset({ + id: "component-css", + filePath: path.resolve(import.meta.dirname, "MyAppPage.css"), +}); -## 9. Deployment checklist +req.Inertia.render({ + component: "module", + props: { moduleComponent, moduleComponentCSS, data }, +}); +``` -- [ ] Bundle contains no duplicated React / shadcn / Radix libs (check with `vite-bundle-visualizer`). -- [ ] All UI elements come from `window.UIComponents` / `window.JSComponents`. -- [ ] `loading`, `error`, and `success` states are handled for every async action. -- [ ] `dev` and `production` paths in `AbstractControls.path` point to the correct built assets. -- [ ] Control is registered inside the `adminizer:loaded` listener. -- [ ] Model field references the control via `options.name`. -- [ ] TypeScript declarations included via `node_modules/adminizer/adminizer-module.d.ts`. +Two limits worth knowing: + +- `moduleComponentCSS` applies to the **page** module. A component the page imports dynamically at + runtime (a nested micro-frontend, a per-record form) is not covered — such a bundle must inject + its own stylesheet, or inline it. +- Duplicate utility definitions are harmless: your file and the panel's declare the same class with + the same value, and later wins with an identical result. Divergence only appears through theme + tokens, which is exactly what the `@theme inline` mapping above prevents. +- **A module stylesheet is global.** It is injected as a `` into `document.head`, after + the panel's own stylesheet and for the whole document — not scoped to the module. Anything + you write into `:root` (directly or through `@theme` without `inline`) lands on every page of + the panel and wins, because it loads last. Keep the file to utilities and the `@theme inline` + mapping above; never redefine a panel variable. + +## Fixture Examples + +The fixture contains several current app-module examples: + +| App | Files | Demonstrates | +|---|---|---| +| `ai-assistant` | `fixture/apps/ai-assistant/*` | App-owned AI assistant handler, model access tokens, standard AI routes, config layer, and runtime enable/disable through `AppManager`. | +| `notification-sender` | `fixture/apps/notification-sender/*` | Page module, asset registration, scoped model access through `req.runtime.models`, user message notifications, UI/API routes, sidebar config patch. | +| `module-manager` | `fixture/apps/module-manager/*` | Access right token, permission-protected page/API routes, app lifecycle control through `req.runtime.apps`. | +| `navigation` | `fixture/apps/navigation/*` | Runtime model, model access, catalog factory, catalog template components, sidebar links, `model:updated` listener. | +| `media-manager` | `fixture/apps/media-manager/*` | Dynamic Sequelize models, app-owned storage implementation, scoped model access, manager registration, lifecycle cleanup. | +| `quill-editor` | `fixture/apps/quill-editor/*` | App-owned WYSIWYG control, lazy component and stylesheet assets, and a dedicated ES module build. | +| `context-system`, `context-support`, `context-studio` | `fixture/apps/contexts/*` | Layout contexts: inherited layout with overridden parts and own navbar, partially inherited layout, fully custom layout. See [Layout Contexts](Layout%20Contexts.md). | + +## Field Controls + +Custom form controls are app-owned resources registered through `ctx.control()`. Their JavaScript and optional stylesheet are loaded only when the field renderer mounts the control. Disabling the app unregisters the control and removes its asset routes. See [Controls.md](Controls.md) for the complete contract, build configuration, field configuration, and fallback behavior. + +## Checklist + +- App has stable `name` and `version`. +- App constructor accepts app config/dependencies, never an `Adminizer` instance. +- App controllers use `req.runtime`; they do not use the deprecated `req.adminizer`. +- All registered resources have deterministic `id` values. +- UI controllers render through `req.Inertia.render({ component: "module", props: { moduleComponent, ... } })`. +- API controllers use `mode: "api"` policies and return JSON. +- Frontend assets are registered through `ctx.asset()` with both `filePath` and `devUrl` when local development is needed. +- Custom form controls are registered through `ctx.control()` with deterministic component and stylesheet asset IDs. +- App-specific models are declared with `ctx.model()` and allowed with `ctx.modelAccess()` before catalogs use them. +- Catalog templates are registered in `ctx.catalog({ templates: [...] })` instead of hard-coded paths in catalog data. +- Module UI uses `window.UIComponents`, `window.JSComponents`, `window.adminApi`, and global React/Lucide exports instead of bundling duplicates. +- Module markup uses only utilities the panel's stylesheet already contains; anything else is an + inline style or ships in the module's own `moduleComponentCSS` file. See Styling App Modules. diff --git a/docs/Catalog.md b/docs/Catalog.md index a5973d06..f7462d60 100644 --- a/docs/Catalog.md +++ b/docs/Catalog.md @@ -1,805 +1,420 @@ # Catalog -## Abstract - -The Adminizer catalog system provides a powerful and flexible way to manage hierarchical (tree-shaped) data structures through a unified interface. Catalogs allow creating editors for any data that have parent-child relationships: navigation menus, product categories, document structures, organizational hierarchies, etc. - -**Why is this needed:** -- **Unified interface**: one UI component for all tree structures -- **Drag & Drop**: dragging elements to change order and nesting -- **Search with context**: searching for elements with automatic display of the parent chain -- **Contextual actions**: custom operations on elements (export, bulk editing, etc.) -- **Element typing**: different types of elements can coexist in one tree (groups, links, model records) -- **Flexible persistence**: saving to the database through any ORM adapter either via parentId, intermediate link model, or the entire catalog tree at once - -**Main features:** -- Creating, editing, deleting elements through modal forms -- Automatic sorting (`sortOrder`) when moving elements -- Multiple catalog instances (e.g., different navigation menus) -- Integration with the Adminizer access rights system -- Support for custom React components for add/edit forms - -## Overview - -The Adminizer catalog system powers tree-shaped resources such as navigation menus, document hierarchies, or any structure that mixes groups with leaf records. Catalogs expose a uniform contract on the backend and deliver pre-normalised data to the frontend so the UI can render, drag, search, and execute contextual actions without knowing the underlying storage. - -Every catalog must be registered in the global [`CatalogHandler`](#cataloghandler). Adminizer exposes the catalog UI at `/admin/catalog/:slug/:id?`, where `slug` selects the catalog and the optional `id` points to a specific storage instance (for example, a navigation section). - -When a catalog exposes exactly one available storage `id`, the frontend treats it as already selected. In that case the UI renders a badge with that `id` instead of the selector dropdown, and if the current route has no `id` yet the client redirects to `/catalog/:slug/:id` automatically. - -## Runtime Architecture - -### AbstractCatalog -`src/lib/catalog/AbstractCatalog.ts:119` defines the abstract base class for any catalog. Its responsibilities: -- Storing catalog metadata (`id`, `name`, `slug`, `icon` and flag `movingGroupsRootOnly`); -- Managing registered element types (`itemTypes`) and global action handlers (`actionHandlers`); -- Delegating CRUD, templates and search queries to the correct element type via `getItemType`; -- Providing helper methods for the controller (`getChilds`, `getActions`, `handleAction`, `getLink`, `getPopUpTemplate`, `createItem`, `updateItem`, `updateModelItems`, `deleteItem`, `getitemTypes`, `search`); -- Registering a catalog-specific access rights token (`catalog-${slug}` or `catalog-${slug}-${id}`) for uniform rights checking. - -`AbstractCatalog` expects concrete implementations to provide `name`, `slug`, `icon` and a list of element type instances via the constructor. Optional catalog-level actions can be added via `addActionHandler`. When `selectedItemTypes` is empty, the handler is considered global; otherwise, it is attached to each matching element type. - -### BaseItem, AbstractGroup, AbstractItem -`BaseItem` models the type of an individual catalog element. Concrete implementations provide: -- Static metadata (`type`, `name`, `icon`, `allowedRoot`, flag `isGroup`); -- Connectors to the Adminizer environment (`adminizer`, optional reference to `storageServices` for shared persistence); -- Data access methods (`find`, `create`, `update`, `deleteItem`, `updateModelItems`, `getChilds`, `search`); -- UI template factories (`getAddTemplate`, `getEditTemplate`), returning either a path to a React component, or a navigation template, or a model-based selector. - -`BaseItem` enriches each returned record with `type` and `icon` fields via the `_enrich` method so that the frontend can render unified nodes. Helper methods `_find` and `_getChilds` apply this enrichment automatically. `AbstractGroup` and `AbstractItem` provide sensible defaults for grouping (`type: "group"`, `icon: "folder"`, `isGroup: true`) and leaf elements (`isGroup: false`). - -### ActionHandler -Action handlers describe operations that can be invoked from the context menu or toolbar. The abstract contract in `src/lib/catalog/AbstractCatalog.ts:203` provides: -- `type`: either `basic`, `external`, or `link`; -- `displayContext` / `displayTool`: placement hints for UI; -- `selectedItemTypes`: restricts handlers to specific element `type` values; empty array means the handler is global; -- `id`, `name`, `icon`: UI descriptors; -- `getPopUpTemplate`, `getLink`: template providers for `external` and `link` actions; -- `handler`: backend implementation that receives permitted `Item` entities plus optional payload. - -Handlers are discovered via `AbstractCatalog.getActions`. `FrontendCatalog.getActions` filters them for use in context or toolbar before returning data to the client. - -## Request Lifecycle -`catalogController` (`src/controllers/catalog/Catalog.ts`) handles HTTP traffic: -- Checks access rights using the rights token registered by the catalog; -- Resolves the catalog by slug and optional storage `id` (also used to get permitted IDs via `getIdList`); -- For `GET` requests renders the Inertia page `catalog`; -- For `POST`, `PUT` and `DELETE` requests creates a `FrontendCatalog` instance and dispatches operations based on `_method`. - -The controller currently supports the following `_method` values: -- `getCatalog`: returns the tree, available element types, catalog metadata and toolbar actions; -- `getChilds`, `createItem`, `updateItem`, `deleteItem`: CRUD endpoints working with specific nodes; -- `getAddTemplate`, `getEditTemplate`: provide UI payloads for modal forms or custom components; -- `search`: returns tagged search results along with their ancestors and siblings; -- `getActions`, `handleAction`, `getLink`, `getPopUpTemplate`: execute or describe action handlers; -- `updateTree`: saves order and parent changes on drag-and-drop, recalculating `sortOrder` and `parentId` of each node. - -## Frontend Adapter -`FrontendCatalog` (`src/controllers/catalog/FrontendCatalogAdapter.ts`) wraps a catalog instance and normalizes responses for the React tree view. Main responsibilities include: -- Converting backend elements to `NodeModel` instances (`FrontendCatalogUtils.arrayToNode`, `treeToNode`); -- Replacing `null` parent IDs with `0` for UI and restoring them on write (`normalizeForFrontend`, `refinement`); -- Resolving localized catalog UI strings via `getLocales` so the client can display multilingual labels; -- Recursively deleting nodes with `deleteItem` to ensure that deleting a group also cleans up its descendants before backend cleanup. - -The helper class `FrontendCatalogUtils` contains stateless converters that React components use directly. When adding new element data points, update these utilities so the frontend continues to receive complete node descriptors. - -## Creating a Custom Catalog -1. **Develop a storage strategy for elements.** Decide how catalog data will be stored. Follow the `StorageService` example or implement persistence API directly in each element type. -2. **Implement element types.** Extend `AbstractGroup` for folders and `AbstractItem` for leaf records. Provide required metadata, CRUD methods and templates. Use `_enrich` via provided helpers if returning raw entities. -3. **Assemble the catalog.** Create a subclass of `AbstractCatalog`, instantiate element types and call `super(adminizer, itemTypes)`. Optionally configure action handlers and override `getIdList` when the catalog supports multiple storage instances. -4. **Register the catalog.** During Adminizer boot add the catalog to the global handler: `adminizer.catalogHandler.add(new MyCatalog(adminizer));` as shown in `src/lib/catalog/CatalogHandler.ts`. -5. **Provide storage identifiers.** If the catalog uses multiple roots, add them to `idList` so the controller can validate incoming `/catalog/:slug/:id` requests. - -## CatalogHandler -`src/lib/catalog/CatalogHandler.ts` stores registered catalogs in memory. It provides three methods: -- `add(catalog: AbstractCatalog)`: stores the catalog instance and returns it; -- `getAll()`: lists all registered catalogs (useful for diagnostics or dashboards); -- `getCatalog(slug: string)`: retrieves the catalog by slug that the controller gets from the request. - -Call `CatalogHandler.add` during Adminizer initialization so the catalog becomes available at `/admin/catalog/:slug`. - -## Example: Simple Product Catalog - -An example of a simple product catalog where groups (categories) and products are linked via `parentId`. It is assumed that `Group` and `Product` models are already defined in Sequelize with fields `id`, `name`, `parentId`, `sortOrder` and `description` for products. - -### Catalog Implementation - -```typescript -// lib/catalog/ProductCatalog.ts -import { AbstractCatalog, AbstractGroup, AbstractItem, Item } from './AbstractCatalog'; -import { Adminizer } from '../Adminizer'; -import Group from '../../models/Group'; -import Product from '../../models/Product'; - -interface CatalogItem extends Item { - description?: string; +Adminizer catalogs provide a shared tree editor for hierarchical data: navigation menus, product categories, page trees, document structures, and other resources with parent-child relations. The backend owns storage and item behavior; the frontend receives normalized nodes and template metadata. + +Catalogs can still be added directly to `CatalogHandler`, but the preferred current pattern is to register catalogs from an `AbstractAdminizerApp` with `ctx.catalog()`. That lets the module register its models, access rights, sidebar links, template components, and event listeners together. + +## Runtime Shape + +A catalog is an `AbstractCatalog` subclass with: + +| Member | Purpose | +|---|---| +| `name` | Human-readable catalog name. | +| `slug` | Route identifier used by `/catalog/:slug/:id?`. Must be unique. | +| `icon` | Material icon name for UI metadata. | +| `id` | Current storage instance id. Set from route params by `FrontendCatalog.setId()`. | +| `itemTypes` | Registered element type instances. Exactly one group item type is allowed. | +| `actionHandlers` | Global catalog actions. Item-specific actions live on item types. | +| `movingGroupsRootOnly` | UI hint that restricts group movement to root level. | + +The item shape is defined by `Item`: + +```ts +interface Item { + id: string | number; + name: string; + parentId: string | number | null; + childs?: Item[]; + sortOrder: number; + icon: string; + type: string; + marked?: boolean; } +``` -// Product Group -class ProductGroup extends AbstractGroup { - readonly type = 'group'; - readonly name = 'Product Group'; - readonly icon = 'folder'; - readonly allowedRoot = true; - - constructor(protected adminizer: Adminizer) { - super(); - } +Use `null` for root-level `parentId`. The frontend adapter converts root `parentId` to `0` for `@minoru/react-dnd-treeview` and converts it back to `null` before writes. - async find(itemId: string | number): Promise { - const group = await Group.findByPk(itemId); - if (!group) throw new Error('Group not found'); - - return { - id: group.id, - name: group.name, - parentId: group.parentId, - sortOrder: group.sortOrder, - type: this.type, - icon: this.icon, - }; - } +## AbstractCatalog Responsibilities - async create(data: any): Promise { - const group = await Group.create({ - name: data.name, - parentId: data.parentId || null, - sortOrder: data.sortOrder || 0, - }); +`AbstractCatalog` delegates all item-specific work to `BaseItem` instances and provides controller-facing helpers: - return { - id: group.id, - name: group.name, - parentId: group.parentId, - sortOrder: group.sortOrder, - type: this.type, - icon: this.icon, - }; - } +| Method | Purpose | +|---|---| +| `getChilds(parentId, byItemType?, req?)` | Load children across all item types or one item type, sorted by `sortOrder`. | +| `find(item, req?)` | Resolve one item through its `type`. | +| `createItem(data, req?)` | Create an item through its `type`. Throws if the type is unknown or the handler returns no item. | +| `updateItem(id, type, data, req?)` | Update one catalog node. Throws on unknown type or empty result. | +| `updateModelItems(modelId, type, data, req?)` | Update catalog nodes linked to a source model record. | +| `deleteItem(type, id, req?)` | Delete one item through its item type. | +| `getAddTemplate(item, req)` | Return add-form template metadata for the selected item type. | +| `getEditTemplate(item, id, req, modelId?)` | Return edit-form template metadata. | +| `getActions(items?)` | Return global actions or actions for one selected item type. | +| `handleAction(actionId, items?, data?, req?)` | Execute a catalog action. | +| `getIdList()` | Return valid storage instance ids, for example navigation sections. Default is `[]`. | +| `search(s, hasExtras?, req?)` | Search all item types and build enough parent context for the tree UI. | - async update(itemId: string | number, data: CatalogItem): Promise { - const group = await Group.findByPk(itemId); - if (!group) throw new Error('Group not found'); - - await group.update({ - name: data.name, - parentId: data.parentId, - sortOrder: data.sortOrder, - }); +`getItemType(type)` is still available, but internal write operations now require a valid item type. This avoids silent `undefined` results and makes template/type mismatches visible as explicit errors. - return { - id: group.id, - name: group.name, - parentId: group.parentId, - sortOrder: group.sortOrder, - type: this.type, - icon: this.icon, - }; - } +## Item Types - async deleteItem(itemId: string | number): Promise { - await Group.destroy({ where: { id: itemId } }); - } +A catalog item type extends either `AbstractGroup` or `AbstractItem`. - async getChilds(parentId: string | number | null): Promise { - const groups = await Group.findAll({ - where: { parentId }, - order: [['sortOrder', 'ASC']], - }); +```ts +class MyGroup extends AbstractGroup { + readonly type = "group"; + readonly name = "Group"; + readonly allowedRoot = true; + readonly adminizer = legacyAdminizerHost; + readonly actionHandlers = []; + + async find(itemId: string | number, catalogId: string, req?: ReqType): Promise {} + async create(data: MyCatalogItem, catalogId: string, req?: ReqType): Promise {} + async update(itemId: string | number, data: MyCatalogItem, catalogId: string, req?: ReqType): Promise {} + async updateModelItems(modelId: string | number, data: any, catalogId: string, req?: ReqType): Promise {} + async deleteItem(itemId: string | number, catalogId: string, req?: ReqType): Promise {} + async getChilds(parentId: string | number | null, catalogId: string, req?: ReqType): Promise {} + async search(s: string, catalogId: string, req?: ReqType): Promise {} + async getAddTemplate(req: ReqType): Promise {} + async getEditTemplate(id: string | number, catalogId: string, req: ReqType, modelId?: string | number): Promise {} +} +``` - return groups.map(group => ({ - id: group.id, - name: group.name, - parentId: group.parentId, - sortOrder: group.sortOrder, - type: this.type, - icon: this.icon, - })); - } +`BaseItem._getChilds()` and `BaseItem._find()` call `_enrich()` automatically. `_enrich()` writes `type` and `icon` to returned items, so raw records do not need to persist those service fields. - async search(s: string): Promise { - const groups = await Group.findAll({ - where: { - name: { [Op.iLike]: `%${s}%` }, - }, - }); +## Templates - return groups.map(group => ({ - id: group.id, - name: group.name, - parentId: group.parentId, - sortOrder: group.sortOrder, - type: this.type, - icon: this.icon, - })); - } +`getAddTemplate()` and `getEditTemplate()` return a `CatalogTemplate`: - async getAddTemplate(req: any): Promise { - return { - type: 'model', - data: { - model: 'Group', - items: [], - labels: { - title: req.i18n.__('Add Group'), - save: req.i18n.__('Save'), - }, - }, - }; - } - - async getEditTemplate(id: string | number, catalogId: string, req: any): Promise { - const item = await this.find(id); - return { - type: 'model', - data: { - item, - model: 'Group', - labels: { - title: req.i18n.__('Edit Group'), - save: req.i18n.__('Save'), - }, - }, - }; - } +```ts +interface CatalogTemplate { + type: string; + data: TData; } +``` -// Product -class ProductItem extends AbstractItem { - readonly type = 'product'; - readonly name = 'Product'; - readonly icon = 'shopping_cart'; - readonly allowedRoot = false; - - constructor(protected adminizer: Adminizer) { - super(); - } - - async find(itemId: string | number): Promise { - const product = await Product.findByPk(itemId); - if (!product) throw new Error('Product not found'); - - return { - id: product.id, - name: product.name, - parentId: product.parentId, - sortOrder: product.sortOrder, - type: this.type, - icon: this.icon, - description: product.description, - }; - } +There are three practical template modes. - async create(data: any): Promise { - const product = await Product.create({ - name: data.name, - parentId: data.parentId, - sortOrder: data.sortOrder || 0, - description: data.description || '', - }); +### Built-in model form - return { - id: product.id, - name: product.name, - parentId: product.parentId, - sortOrder: product.sortOrder, - type: this.type, - icon: this.icon, - description: product.description, - }; - } +Return `type: "model"` when the catalog should open the standard Adminizer add/edit form for a model: - async update(itemId: string | number, data: CatalogItem): Promise { - const product = await Product.findByPk(itemId); - if (!product) throw new Error('Product not found'); - - await product.update({ - name: data.name, - parentId: data.parentId, - sortOrder: data.sortOrder, - description: data.description, - }); - - return { - id: product.id, - name: product.name, - parentId: product.parentId, - sortOrder: product.sortOrder, - type: this.type, - icon: this.icon, - description: product.description, - }; - } +```ts +return { + type: "model", + data: { + model: "Product", + }, +}; +``` - async deleteItem(itemId: string | number): Promise { - await Product.destroy({ where: { id: itemId } }); - } +### Legacy dynamic component path - async getChilds(parentId: string | number | null): Promise { - const products = await Product.findAll({ - where: { parentId }, - order: [['sortOrder', 'ASC']], - }); +Older catalogs can return a custom component path in `data.path`. The catalog UI imports it directly. This remains supported for compatibility, but new modules should prefer registered catalog template components. - return products.map(product => ({ - id: product.id, - name: product.name, - parentId: product.parentId, - sortOrder: product.sortOrder, - type: this.type, - icon: this.icon, - description: product.description, - })); - } +```ts +return { + type: "component", + data: { + path: myCatalogFormUrl, + }, +}; +``` - async search(s: string): Promise { - const products = await Product.findAll({ - where: { - [Op.or]: [ - { name: { [Op.iLike]: `%${s}%` } }, - { description: { [Op.iLike]: `%${s}%` } }, - ], - }, - }); +Use `adminizer.assetHandler.register(...)` or `ctx.asset(...)` to produce `myCatalogFormUrl`; do not rely on manually copied files under the static assets directory. In development, the registered asset can point at a Vite-served `.tsx` URL such as `/fixture/virtual-catalog/group.tsx`; in production, `AssetHandler` serves the built `.es.js` file through an app asset route. - return products.map(product => ({ - id: product.id, - name: product.name, - parentId: product.parentId, - sortOrder: product.sortOrder, - type: this.type, - icon: this.icon, - description: product.description, - })); - } +### Registered catalog template component - async getAddTemplate(req: any): Promise { - return { - type: 'model', - data: { - model: 'Product', - labels: { - title: req.i18n.__('Add Product'), - save: req.i18n.__('Save'), - }, - }, - }; - } +Current module-based catalogs should return a stable template type and register the React component in the `templates` array of `ctx.catalog()`: - async getEditTemplate(id: string | number, catalogId: string, req: any): Promise { - const item = await this.find(id); - return { - type: 'model', - data: { - item, - model: 'Product', - labels: { - title: req.i18n.__('Edit Product'), - save: req.i18n.__('Save'), - }, +```ts +// Item type +async getAddTemplate(req: ReqType) { + return { + type: "products.product-form", + data: { + labels: { + title: req.i18n.__("Product"), + save: req.i18n.__("Save"), }, - }; - } -} - -// Product Catalog -export class ProductCatalog extends AbstractCatalog { - readonly name = 'Product Catalog'; - readonly slug = 'products'; - readonly icon = 'inventory'; - - constructor(adminizer: Adminizer) { - const itemTypes = [ - new ProductGroup(adminizer), - new ProductItem(adminizer), - ]; - super(adminizer, itemTypes); - } + }, + }; } ``` -### Catalog Registration - -```typescript -// In the Adminizer initialization file -import { ProductCatalog } from './lib/catalog/ProductCatalog'; - -const adminizer = new Adminizer(/* config */); - -// Register the catalog -adminizer.catalogHandler.add(new ProductCatalog(adminizer)); +```ts +// App setup +ctx.catalog({ + id: "products", + templates: [{ + id: "product-form", + type: "products.product-form", + component: productTemplatesAsset, + exportName: "ProductFormTemplate", + }], + factory: (runtime) => new ProductsCatalog(runtime), +}); ``` -Now the catalog will be available at `/admin/catalog/products` and will allow managing the hierarchy of groups and products through the drag & drop interface. +When `getCatalog` is called, the controller resolves the registered template components for the catalog slug. The frontend matches `template.type.toLowerCase()` against those records, dynamically imports `component`, and renders `exportName` or the default export. -## Обзор +Template props are documented in [BuildingModules.md](BuildingModules.md#catalog-template-components). -The Adminizer catalog system powers tree-shaped resources such as navigation menus, document hierarchies, or any structure that mixes groups with leaf records. Catalogs expose a uniform contract on the backend and deliver pre-normalised data to the frontend so the UI can render, drag, search, and execute contextual actions without knowing the underlying storage. +## Controller Lifecycle -Every catalog must be registered in the global [`CatalogHandler`](#cataloghandler). Adminizer exposes the catalog UI at `/admin/catalog/:slug/:id?`, where `slug` selects the catalog and the optional `id` points to a specific storage instance (for example, a navigation section). +`catalogController` handles `/catalog/:slug/:id?`: -## Runtime Architecture +1. Resolve catalog by `slug` from `CatalogHandler`. +2. Validate optional `id` against `catalog.getIdList()` when the list is non-empty. +3. Render Inertia page `catalog` for `GET`. +4. For `POST`, `PUT`, and `DELETE`, create `FrontendCatalog`, set catalog id, and dispatch by `_method`. -### AbstractCatalog -`src/lib/catalog/AbstractCatalog.ts:119` определяет абстрактный базовый класс для любого каталога. Его обязанности: -- Хранение метаданных каталога (`id`, `name`, `slug`, `icon` и флаг `movingGroupsRootOnly`); -- Управление зарегистрированными типами элементов (`itemTypes`) и глобальными обработчиками действий (`actionHandlers`); -- Делегирование CRUD, шаблонов и поисковых запросов правильному типу элемента через `getItemType`; -- Предоставление вспомогательных методов для контроллера (`getChilds`, `getActions`, `handleAction`, `getLink`, `getPopUpTemplate`, `createItem`, `updateItem`, `updateModelItems`, `deleteItem`, `getitemTypes`, `search`); -- Регистрация специфичного для каталога токена прав доступа (`catalog-${slug}` или `catalog-${slug}-${id}`) для единообразной проверки прав. +Supported `_method` values: -`AbstractCatalog` ожидает, что конкретные реализации предоставят `name`, `slug`, `icon` и список экземпляров типов элементов через конструктор. Опциональные действия уровня каталога можно добавить через `addActionHandler`. Когда `selectedItemTypes` пуст, обработчик считается глобальным; в противном случае он прикрепляется к каждому совпадающему типу элемента. +| Method | HTTP | Purpose | +|---|---|---| +| `getCatalog` | `POST` | Return root nodes, item types, catalog metadata, toolbar actions, and registered template components. | +| `getAddTemplate` | `POST` | Return add template for selected item type. | +| `getEditTemplate` | `POST` | Return edit template for selected node. | +| `createItem` | `POST` | Create item and normalize returned `parentId` for frontend. | +| `getChilds` | `POST` | Lazy-load children for a node. | +| `search` | `POST` | Return tree nodes for matching items with parent context. | +| `getActions` | `POST` | Return context/tool actions for selected nodes. | +| `updateTree` | `PUT` | Persist drag-and-drop parent/order changes. | +| `updateItem` | `PUT` | Update item or linked model item. | +| `handleAction` | `PUT` | Execute action handler. | +| `getLink` | `PUT` | Resolve link action URL. | +| `getPopUpTemplate` | `PUT` | Resolve external action component. | +| `deleteItem` | `DELETE` | Recursively delete selected item and descendants. | -### BaseItem, AbstractGroup, AbstractItem -`BaseItem` моделирует тип отдельного элемента каталога. Конкретные реализации предоставляют: -- Статические метаданные (`type`, `name`, `icon`, `allowedRoot`, флаг `isGroup`); -- Коннекторы к окружению Adminizer (`adminizer`, опциональная ссылка на `storageServices` для общей персистентности); -- Методы доступа к данным (`find`, `create`, `update`, `deleteItem`, `updateModelItems`, `getChilds`, `search`); -- Фабрики UI-шаблонов (`getAddTemplate`, `getEditTemplate`), возвращающие либо путь к React-компоненту, либо шаблон навигации, либо селектор на основе модели. +## Frontend Adapter + +`FrontendCatalog` and `FrontendCatalogUtils` convert backend items to tree nodes: -`BaseItem` обогащает каждую возвращаемую запись полями `type` и `icon` через метод `_enrich`, чтобы фронтенд мог рендерить унифицированные узлы. Вспомогательные методы `_find` и `_getChilds` применяют это обогащение автоматически. `AbstractGroup` и `AbstractItem` предоставляют разумные значения по умолчанию для группировки (`type: "group"`, `icon: "folder"`, `isGroup: true`) и листовых элементов (`isGroup: false`). +```ts +interface NodeModel { + text: string; + droppable: boolean; + id: string; + parent: number; + data?: TDataType; + children?: NodeModel[]; +} +``` -### ActionHandler -Обработчики действий описывают операции, которые могут быть вызваны из контекстного меню или панели инструментов. Абстрактный контракт в `src/lib/catalog/AbstractCatalog.ts:203` предоставляет: -- `type`: либо `basic`, `external`, либо `link`; -- `displayContext` / `displayTool`: подсказки размещения для UI; -- `selectedItemTypes`: ограничивает обработчики определенными значениями `type` элемента; пустой массив означает, что обработчик глобальный; -- `id`, `name`, `icon`: дескрипторы UI; -- `getPopUpTemplate`, `getLink`: провайдеры шаблонов для действий `external` и `link`; -- `handler`: бэкенд-реализация, которая получает разрешенные сущности `Item` плюс опциональный payload. +Important behavior: -Обработчики обнаруживаются через `AbstractCatalog.getActions`. `FrontendCatalog.getActions` фильтрует их для использования в контексте или панели инструментов перед возвратом данных клиенту. +- `getCatalog()` loads root items with `catalog.getChilds(null)`. +- `getChilds()` unwraps `node.data`, treats `0` and missing id as root, and returns child nodes. +- `updateTree()` updates every child under the dropped parent with recalculated `sortOrder` and `parentId`. +- `deleteItem()` recursively deletes children before deleting the selected node. +- `normalizeForFrontend()` converts `parentId: null` to `parentId: 0` and now throws if a catalog handler returns an empty item. +## CatalogHandler -## Жизненный цикл запроса -`catalogController` (`src/controllers/catalog/Catalog.ts`) обрабатывает HTTP-трафик: -- Проверяет права доступа используя токен прав доступа, зарегистрированный каталогом; -- Разрешает каталог по slug и опциональному `id` хранилища (также используется для получения разрешенных ID через `getIdList`); -- Для `GET` запросов рендерит Inertia-страницу `catalog`; -- Для `POST`, `PUT` и `DELETE` запросов создает экземпляр `FrontendCatalog` и диспетчеризует операции на основе `_method`. +`CatalogHandler` stores two catalog groups: -Контроллер в настоящее время поддерживает следующие значения `_method`: -- `getCatalog`: возвращает дерево, доступные типы элементов, метаданные каталога и действия панели инструментов; -- `getChilds`, `createItem`, `updateItem`, `deleteItem`: CRUD-эндпоинты, работающие с конкретными узлами; -- `getAddTemplate`, `getEditTemplate`: предоставляют UI-полезные нагрузки для модальных форм или кастомных компонентов; -- `search`: возвращает помеченные результаты поиска вместе с их предками и соседями; -- `getActions`, `handleAction`, `getLink`, `getPopUpTemplate`: выполняют или описывают обработчики действий; -- `updateTree`: сохраняет изменения порядка и родителей при drag-and-drop, пересчитывая `sortOrder` и `parentId` каждого узла. +| Source | API | Use case | +|---|---|---| +| Core/manual catalogs | `catalogHandler.add(catalog)` | Legacy direct registration. | +| App catalogs | `catalogHandler.register(appName, catalog)` | Preferred module-owned registration through `ctx.catalog()`. | -## Frontend Adapter (Адаптер фронтенда) -`FrontendCatalog` (`src/controllers/catalog/FrontendCatalogAdapter.ts`) оборачивает экземпляр каталога и нормализует ответы для React-представления дерева. Основные обязанности включают: -- Конвертирование backend-элементов в экземпляры `NodeModel` (`FrontendCatalogUtils.arrayToNode`, `treeToNode`); -- Замену `null` parent IDs на `0` для UI и восстановление их при записи (`normalizeForFrontend`, `refinement`); -- Разрешение локализованных UI-строк каталогов через `getLocales`, чтобы клиент мог отображать мультиязычные метки; -- Рекурсивное удаление узлов с помощью `deleteItem` для гарантии, что удаление группы также очищает её потомков перед очисткой на бэкенде. +`register()` rejects duplicate app/catalog ids and duplicate catalog slugs. `unregister()`, `disable()`, and `enable()` are used by `AppManager` disposers. -Вспомогательный класс `FrontendCatalogUtils` содержит stateless-конвертеры, которые React-компоненты используют напрямую. При добавлении новых точек данных элементов, обновляйте эти утилиты, чтобы фронтенд продолжал получать полные дескрипторы узлов. +## App-Based Catalog Example -## Создание кастомного каталога -1. **Разработайте стратегию хранения элементов.** Решите, как будут сохраняться данные каталога. Следуйте примеру `StorageService` или реализуйте API персистентности прямо в каждом типе элемента. -2. **Реализуйте типы элементов.** Расширьте `AbstractGroup` для папок и `AbstractItem` для листовых записей. Предоставьте требуемые метаданные, CRUD-методы и шаблоны. Используйте `_enrich` через предоставленные хелперы, если возвращаете сырые сущности. -3. **Соберите каталог.** Создайте подкласс `AbstractCatalog`, создайте экземпляры типов элементов и вызовите `super(adminizer, itemTypes)`. Опционально настройте обработчики действий и переопределите `getIdList`, когда каталог поддерживает множественные экземпляры хранилища. -4. **Зарегистрируйте каталог.** Во время загрузки Adminizer добавьте каталог в глобальный обработчик: `adminizer.catalogHandler.add(new MyCatalog(adminizer));` как показано в `src/lib/catalog/CatalogHandler.ts`. -5. **Предоставьте идентификаторы хранилища.** Если каталог использует множественные корни, добавьте их в `idList`, чтобы контроллер мог валидировать входящие запросы `/catalog/:slug/:id`. +```ts +import { + AbstractAdminizerApp, + AbstractCatalog, + AbstractGroup, + AbstractItem, + AppRuntime, + AppSetupContext, + Item, +} from "adminizer"; -## CatalogHandler -`src/lib/catalog/CatalogHandler.ts` хранит зарегистрированные каталоги в памяти. Он предоставляет три метода: -- `add(catalog: AbstractCatalog)`: сохраняет экземпляр каталога и возвращает его; -- `getAll()`: перечисляет все зарегистрированные каталоги (полезно для диагностики или дашбордов); -- `getCatalog(slug: string)`: получает каталог по slug, который контроллер получает из запроса. +interface TreeItem extends Item { + url?: string; +} -Вызывайте `CatalogHandler.add` во время инициализации Adminizer, чтобы каталог стал доступен по адресу `/admin/catalog/:slug`. +class MemoryStorage { + private items = new Map(); -## Пример: Простой продукт каталог + async set(item: TreeItem): Promise { + this.items.set(item.id, item); + return item; + } -Пример простого каталога продуктов, где группы (категории) и продукты связаны через `parentId`. Предполагается, что модели `Group` и `Product` уже определены в Sequelize с полями `id`, `name`, `parentId`, `sortOrder` и `description` для продуктов. + async find(id: string | number): Promise { + const item = this.items.get(id); + if (!item) throw new Error(`Item ${id} was not found`); + return item; + } -### Реализация каталога + async children(parentId: string | number | null, type?: string): Promise { + return Array.from(this.items.values()) + .filter((item) => item.parentId === parentId && (!type || item.type === type)) + .sort((a, b) => a.sortOrder - b.sortOrder); + } -```typescript -// lib/catalog/ProductCatalog.ts -import { AbstractCatalog, AbstractGroup, AbstractItem, Item } from './AbstractCatalog'; -import { Adminizer } from '../Adminizer'; -import Group from '../../models/Group'; -import Product from '../../models/Product'; + async delete(id: string | number): Promise { + this.items.delete(id); + } -interface CatalogItem extends Item { - description?: string; + async search(query: string, type: string): Promise { + return Array.from(this.items.values()).filter((item) => + item.type === type && item.name.toLowerCase().includes(query.toLowerCase()) + ); + } } -// Product group -class ProductGroup extends AbstractGroup { - readonly type = 'group'; - readonly name = 'Product Group'; - readonly icon = 'folder'; +class GroupItem extends AbstractGroup { + readonly name = "Group"; readonly allowedRoot = true; + readonly adminizer = {} as any; + readonly actionHandlers = []; - constructor(protected adminizer: Adminizer) { + constructor(private storage: MemoryStorage) { super(); } - async find(itemId: string | number): Promise { - const group = await Group.findByPk(itemId); - if (!group) throw new Error('Group not found'); - - return { - id: group.id, - name: group.name, - parentId: group.parentId, - sortOrder: group.sortOrder, - type: this.type, - icon: this.icon, - }; + async find(id: string | number): Promise { + return this.storage.find(id); } - async create(data: any): Promise { - const group = await Group.create({ - name: data.name, - parentId: data.parentId || null, - sortOrder: data.sortOrder || 0, - }); - - return { - id: group.id, - name: group.name, - parentId: group.parentId, - sortOrder: group.sortOrder, - type: this.type, + async create(data: TreeItem): Promise { + return this.storage.set({ + ...data, + id: crypto.randomUUID(), + parentId: data.parentId ?? null, + sortOrder: data.sortOrder ?? 0, icon: this.icon, - }; - } - - async update(itemId: string | number, data: CatalogItem): Promise { - const group = await Group.findByPk(itemId); - if (!group) throw new Error('Group not found'); - - await group.update({ - name: data.name, - parentId: data.parentId, - sortOrder: data.sortOrder, - }); - - return { - id: group.id, - name: group.name, - parentId: group.parentId, - sortOrder: group.sortOrder, type: this.type, - icon: this.icon, - }; + }); } - async deleteItem(itemId: string | number): Promise { - await Group.destroy({ where: { id: itemId } }); + async update(id: string | number, data: TreeItem): Promise { + return this.storage.set({ ...data, id }); } - async getChilds(parentId: string | number | null): Promise { - const groups = await Group.findAll({ - where: { parentId }, - order: [['sortOrder', 'ASC']], - }); + async updateModelItems(modelId: string | number, data: TreeItem): Promise { + return this.update(modelId, data); + } - return groups.map(group => ({ - id: group.id, - name: group.name, - parentId: group.parentId, - sortOrder: group.sortOrder, - type: this.type, - icon: this.icon, - })); + async deleteItem(id: string | number): Promise { + await this.storage.delete(id); } - async search(s: string): Promise { - const groups = await Group.findAll({ - where: { - name: { [Op.iLike]: `%${s}%` }, - }, - }); + async getChilds(parentId: string | number | null): Promise { + return this.storage.children(parentId, this.type); + } - return groups.map(group => ({ - id: group.id, - name: group.name, - parentId: group.parentId, - sortOrder: group.sortOrder, - type: this.type, - icon: this.icon, - })); + async search(s: string): Promise { + return this.storage.search(s, this.type); } - async getAddTemplate(req: any): Promise { + async getAddTemplate(req: ReqType) { return { - type: 'model', - data: { - model: 'Group', - items: [], - labels: { - title: req.i18n.__('Add Group'), - save: req.i18n.__('Save'), - }, - }, + type: "demo.group-template", + data: { labels: { title: req.i18n.__("Title"), save: req.i18n.__("Save") } }, }; } - async getEditTemplate(id: string | number, catalogId: string, req: any): Promise { - const item = await this.find(id); + async getEditTemplate(id: string | number, catalogId: string, req: ReqType) { return { - type: 'model', - data: { - item, - model: 'Group', - labels: { - title: req.i18n.__('Edit Group'), - save: req.i18n.__('Save'), - }, - }, + type: "demo.group-template", + data: { item: await this.find(id), labels: { title: req.i18n.__("Title"), save: req.i18n.__("Save") } }, }; } } -// Product -class ProductItem extends AbstractItem { - readonly type = 'product'; - readonly name = 'Product'; - readonly icon = 'shopping_cart'; - readonly allowedRoot = false; +class LinkItem extends AbstractItem { + readonly type = "link"; + readonly name = "Link"; + readonly icon = "insert_link"; + readonly allowedRoot = true; + readonly adminizer = {} as any; + readonly actionHandlers = []; - constructor(protected adminizer: Adminizer) { + constructor(private storage: MemoryStorage) { super(); } - async find(itemId: string | number): Promise { - const product = await Product.findByPk(itemId); - if (!product) throw new Error('Product not found'); - - return { - id: product.id, - name: product.name, - parentId: product.parentId, - sortOrder: product.sortOrder, - type: this.type, - icon: this.icon, - description: product.description, - }; - } - - async create(data: any): Promise { - const product = await Product.create({ - name: data.name, - parentId: data.parentId, - sortOrder: data.sortOrder || 0, - description: data.description || '', - }); - - return { - id: product.id, - name: product.name, - parentId: product.parentId, - sortOrder: product.sortOrder, - type: this.type, - icon: this.icon, - description: product.description, - }; - } + async find(id: string | number): Promise { return this.storage.find(id); } + async create(data: TreeItem): Promise { return this.storage.set({ ...data, id: crypto.randomUUID(), parentId: data.parentId ?? null, sortOrder: data.sortOrder ?? 0, icon: this.icon, type: this.type }); } + async update(id: string | number, data: TreeItem): Promise { return this.storage.set({ ...data, id }); } + async updateModelItems(modelId: string | number, data: TreeItem): Promise { return this.update(modelId, data); } + async deleteItem(id: string | number): Promise { await this.storage.delete(id); } + async getChilds(parentId: string | number | null): Promise { return this.storage.children(parentId, this.type); } + async search(s: string): Promise { return this.storage.search(s, this.type); } + async getAddTemplate() { return { type: "demo.link-template", data: {} }; } + async getEditTemplate(id: string | number) { return { type: "demo.link-template", data: { item: await this.find(id) } }; } +} - async update(itemId: string | number, data: CatalogItem): Promise { - const product = await Product.findByPk(itemId); - if (!product) throw new Error('Product not found'); - - await product.update({ - name: data.name, - parentId: data.parentId, - sortOrder: data.sortOrder, - description: data.description, - }); +class DemoCatalog extends AbstractCatalog { + readonly name = "Demo Catalog"; + readonly slug = "demo"; + readonly icon = "account_tree"; + readonly actionHandlers = []; - return { - id: product.id, - name: product.name, - parentId: product.parentId, - sortOrder: product.sortOrder, - type: this.type, - icon: this.icon, - description: product.description, - }; + constructor(_runtime: AppRuntime) { + const storage = new MemoryStorage(); + super({ accessRightsHelper: { registerToken: () => undefined } } as any, [ + new GroupItem(storage), + new LinkItem(storage), + ]); } - async deleteItem(itemId: string | number): Promise { - await Product.destroy({ where: { id: itemId } }); + async getIdList(): Promise { + return ["main"]; } +} - async getChilds(parentId: string | number | null): Promise { - const products = await Product.findAll({ - where: { parentId }, - order: [['sortOrder', 'ASC']], - }); +export class DemoCatalogApp extends AbstractAdminizerApp<{ routePrefix: string }> { + readonly name = "demo-catalog"; + readonly version = "1.0.0"; + declare readonly config: { routePrefix: string }; - return products.map(product => ({ - id: product.id, - name: product.name, - parentId: product.parentId, - sortOrder: product.sortOrder, - type: this.type, - icon: this.icon, - description: product.description, - })); - } - - async search(s: string): Promise { - const products = await Product.findAll({ - where: { - [Op.or]: [ - { name: { [Op.iLike]: `%${s}%` } }, - { description: { [Op.iLike]: `%${s}%` } }, - ], + constructor(config: { routePrefix: string }) { + super(); + this.config = config; + } + + setup(ctx: AppSetupContext): void { + ctx.config({ + navbar: { + additionalLinks: [{ + id: "demo-catalog", + type: "self", + link: `${this.config.routePrefix}/catalog/demo/main`, + title: "Demo Catalog", + icon: "account_tree", + }], }, }); - return products.map(product => ({ - id: product.id, - name: product.name, - parentId: product.parentId, - sortOrder: product.sortOrder, - type: this.type, - icon: this.icon, - description: product.description, - })); - } - - async getAddTemplate(req: any): Promise { - return { - type: 'model', - data: { - model: 'Product', - labels: { - title: req.i18n.__('Add Product'), - save: req.i18n.__('Save'), - }, - }, - }; - } - - async getEditTemplate(id: string | number, catalogId: string, req: any): Promise { - const item = await this.find(id); - return { - type: 'model', - data: { - item, - model: 'Product', - labels: { - title: req.i18n.__('Edit Product'), - save: req.i18n.__('Save'), - }, - }, - }; - } -} - -// Product catalog -export class ProductCatalog extends AbstractCatalog { - readonly name = 'Product Catalog'; - readonly slug = 'products'; - readonly icon = 'inventory'; - - constructor(adminizer: Adminizer) { - const itemTypes = [ - new ProductGroup(adminizer), - new ProductItem(adminizer), - ]; - super(adminizer, itemTypes); + ctx.catalog({ + id: "demo", + factory: (runtime) => new DemoCatalog(runtime), + }); } } ``` -### Регистрация каталога +Enable after Adminizer initialization: -```typescript -// In the Adminizer bootstrap file -import { ProductCatalog } from './lib/catalog/ProductCatalog'; +```ts +await adminizer.init(adminpanelConfig); +await adminizer.appManager.enable(new DemoCatalogApp({ routePrefix: adminpanelConfig.routePrefix })); +``` -const adminizer = new Adminizer(/* config */); +## Navigation As A Catalog Module -// Catalog registration -adminizer.catalogHandler.add(new ProductCatalog(adminizer)); -``` +Navigation is no longer part of Adminizer core config. The fixture implements it as an app module in `fixture/apps/navigation`: + +- `NavigationApp.ts` registers access rights, assets, template components, a runtime storage model, model access, navbar links, the catalog factory, and a `model:updated` listener. +- `NavigationCatalog.ts` implements `AbstractCatalog`, item types, and in-memory section storages backed by the runtime model. +- `NavigationCatalogTemplates.tsx` implements add/edit forms registered through `CatalogTemplateComponentHandler`. -Теперь каталог будет доступен по адресу `/admin/catalog/products` и позволит управлять иерархией групп и продуктов через drag & drop интерфейс. +Use this fixture as the current reference for multi-section catalog modules. diff --git a/docs/Commands.md b/docs/Commands.md index c9722bb9..47986b8f 100644 --- a/docs/Commands.md +++ b/docs/Commands.md @@ -1,48 +1,81 @@ - -## Adminizer Commands - +## Adminizer Commands + ## Development + - **`npm run dev`** - Starts the application in development mode using Sequelize with file watching. -- **`npm run dev:waterline`** - Starts the application in development mode using Waterline with file watching. - -- **`npm run watch:backend`** - Watches for changes in backend files and recompiles TypeScript continuously. - -## Build Commands -- **`npm run build:assets`** - Builds frontend assets using Vite. - -- **`npm run build:backend`** - Combines backend copy and compilation steps. - -- **`npm run build`** - Full build process: copies backend, compiles backend, and builds assets. - -## Module-specific Builds (this is just for tests, as an example, you should create your own commands to build modules) -- **`npm run build:module`** - Builds test modules using a custom Vite config. - -- **`npm run build:react-quill`** - Builds the React-Quill WYSIWYG module using a custom Vite config. - -- **`npm run copy:modules`** - Copies modules using `copy-modules.js` script. - -- **`npm run build:assets:modules`** - Builds assets and copies modules. - -## Demo & Seeding -- **`npm run start:seed`** - Starts the application with seed data enabled (using `SEED_DATA=true`). - -- **`npm run demo:build`** - Prepares a demo build: copies backend, compiles backend, builds assets, and copies modules. - -- **`npm run demo`** - Alias for `start:seed` - runs the demo with seeded data. - -## Preview mode -- **`npm start`** - Starts the application using TSX with the Waterline fixture configuration. \ No newline at end of file + Starts the Sequelize fixture in development mode with file watching and seed data. + +- **`npm run dev:no-seed`** + Starts the Sequelize fixture in development mode without seed data. + +- **`npm run dev:typeorm`** + Starts the experimental TypeORM fixture in development mode with file watching and seed data. + +- **`npm run dev:typeorm:no-seed`** + Starts the experimental TypeORM fixture in development mode without seed data. + +- **`npm run dev:cors`** + Starts the Sequelize fixture with CORS settings for frontend integration testing. + +- **`npm run dev:no-seed-clean`** + Starts the Sequelize fixture without seed data and removes `.tmp` before startup. + +- **`npm run tsc:watch`** + Watches backend TypeScript and recompiles continuously. + +Development mode is detected with `ADMINIZER_ENV=dev`. The bundled `dev` scripts set it automatically. In this mode app assets registered with `ctx.asset({ devUrl })` use `devUrl` instead of the production file. + +## Start Commands + +- **`npm run start`** + Starts the Sequelize fixture without seed data. + +- **`npm run start:seed`** + Starts the Sequelize fixture with seed data. + +- **`npm run start:typeorm`** + Starts the experimental TypeORM fixture without seed data. + +- **`npm run start:typeorm:seed`** + Starts the experimental TypeORM fixture with seed data. + +## Build Commands + +- **`npm run build`** + Full package build: copy backend, compile backend, compile UI, and build frontend assets. + +- **`npm run build:backend`** + Copy backend files and compile backend TypeScript. + +- **`npm run compile:backend`** + Compile backend TypeScript with `src/tsconfig.json` and append `.js` extensions in generated imports. + +- **`npm run compile:ui`** + Compile the shared UI TypeScript package. + +- **`npm run build:assets`** + Build the main Adminizer frontend assets and all built-in control ES modules with Vite. + +- **`npm run build:controls`** + Build only the built-in control ES modules and their styles. + +- **`npm run copy:backend`** + Copy backend resources before compilation, including `translations`, `files`, and `fileicons` used by the core media manager routes. + +## App And Module Builds + +- **`npm run build:apps`** + Builds all fixture app frontend modules and fixture virtual catalog components: notification-sender, module-manager, navigation catalog templates, React-Quill, and virtual catalog React templates/actions. + +- **`npm run build:catalog-modules`** + Builds fixture virtual catalog React components from `fixture/virtual-catalog`. + +Project modules should usually define their own Vite build script. See [BuildingModules.md](BuildingModules.md) for current `AbstractAdminizerApp` and `AppManager` usage. + +## Tests + +- **`npm run test`** + Runs Vitest once with `--watch=false`. + +- **`npm run test:watch`** + Runs Vitest in watch/verbose mode. diff --git a/docs/Configuration/CustomComponents.md b/docs/Configuration/CustomComponents.md index f57f1ba8..b9d822ee 100644 --- a/docs/Configuration/CustomComponents.md +++ b/docs/Configuration/CustomComponents.md @@ -4,21 +4,25 @@ Adminizer can be extended with custom controls and dashboard widgets. ## Controls -Controls are reusable form inputs. Create a class extending `AbstractControls` and register it via `ControlsHandler`: +Controls are reusable form inputs loaded only when their field is rendered. Custom controls are registered as app-owned resources through `ctx.control()`: ```ts -class ReactQuill extends AbstractControls { - readonly name = 'react-quill'; - readonly type = 'wysiwyg'; - readonly path = { - jsPath: { dev: '/modules/react-quill.tsx', production: '/assets/react-quill.es.js' }, - cssPath: '/assets/react-quill.css' - }; +setup(ctx: AppSetupContext): void { + ctx.control({ + type: "wysiwyg", + name: "react-quill", + component: { + id: "editor", + filePath: path.resolve(import.meta.dirname, "react-quill-editor.es.js"), + devUrl: "/apps/react-quill/react-quill-editor.tsx", + }, + stylesheet: { + id: "editor-css", + filePath: path.resolve(import.meta.dirname, "react-quill-editor.css"), + devUrl: "/apps/react-quill/react-quill-editor.css", + }, + }); } - -adminizer.emitter.on('adminizer:loaded', () => { - adminizer.controlsHandler.add(new ReactQuill(adminizer)); -}); ``` After registration the control can be referenced in field options: @@ -31,6 +35,8 @@ editor: { } ``` +Enable the owning app after `adminizer.init()`. See [Controls](../Controls.md) for the complete app, Vite build, lazy-loading, and fallback behavior. + ## Widgets Widgets are dashboard blocks. See [Widgets](Widgets.md) for a detailed example of creating and bundling custom widgets. diff --git a/docs/Configuration/Fields.md b/docs/Configuration/Fields.md index bd27d825..c51e35ca 100644 --- a/docs/Configuration/Fields.md +++ b/docs/Configuration/Fields.md @@ -19,4 +19,19 @@ Field definitions can be placed globally under `models.fields` or inside an acti ### Types -Commonly used field types include `string`, `password`, `date`, `datetime`, `integer`, `boolean`, `text`, `select` and more. When a `text` field has the `editor` option, a WYSIWYG control will be used. +Commonly used field types include `string`, `password`, `date`, `datetime`, `integer`, `boolean`, `text`, `select`, `wysiwyg`, `markdown`, `table`, `jsonEditor`, `codeEditor`, and `geoJson`. + +Control fields can select a registered implementation by name: + +```ts +editor: { + title: "Editor", + type: "wysiwyg", + options: { + name: "react-quill", + config: {}, + }, +} +``` + +Control JavaScript and CSS are loaded when the field is rendered. If the named control is unavailable, Adminizer logs a server warning and uses the built-in default for that control type. See [Controls](../Controls.md). diff --git a/docs/Configuration/General.md b/docs/Configuration/General.md index 7711c6ab..4099f047 100644 --- a/docs/Configuration/General.md +++ b/docs/Configuration/General.md @@ -8,7 +8,7 @@ import { AdminizerConfig } from "adminizer"; const config: AdminizerConfig = { routePrefix: "/admin", favicon: "/brand/admin-favicon.ico", - auth: true, + auth: { enable: true }, dashboard: true, models: {}, }; @@ -20,15 +20,23 @@ const config: AdminizerConfig = { |--------|-------------| | `routePrefix` | Base URL for the panel. Defaults to `/admin`. | | `favicon` | Custom favicon URL. Relative values are resolved from `routePrefix`; default is `/files/favicon.png`. | +| `bodyLimit` | Largest request body the panel accepts as JSON or urlencoded: bytes or a string such as `"20mb"`. Default `100kb`. Raise it only where whole files are posted as JSON; the parsers run before access policies, so it is also what an anonymous request may make the server read. | +| `auth` | Authentication settings, for example `{ enable: true, captcha: true }`. | | `linkAssets` | Symlink static assets instead of copying them. | | `identifierField` | Default primary key for models (usually `id`). | -| `showORMtime` | Show `createdAt`/`updatedAt` fields in forms. | +| `showORMtime` | Show `createdAt`/`updatedAt` fields on create/edit pages. | | `models` | Object with model definitions. | | `dashboard` | Enable dashboard widgets. | | `showVersion` | Display Adminizer version in the sidebar. | +| `system.defaultORM` | Adapter that owns the required system models. Sequelize is recommended; TypeORM is experimental. | +| `system.internalModelAccess` | Extend allowlisted internal model access scopes for trusted system modules. | Additional options like `welcome`, `translation` and `administrator` credentials can also be provided. +## Migrations + +Starting with Adminizer 5, built-in migrations are not supported. Projects should manage database schema changes with their own ORM or migration tool. For Sequelize projects, register Adminizer system models and synchronize or migrate them through the host application's database workflow. See [System Models](Models.md). + **`favicon` examples** ```ts @@ -44,3 +52,29 @@ favicon: "files/my-custom-favicon.png" // -> /admin/files/my-custom-favicon.png // 4) full external URL: favicon: "https://cdn.example.com/admin/favicon.svg" ``` + +## Internal Model Access + +Adminizer system modules can use `modelHandler.internal(scope)` to access selected internal models without `DataAccessor` user filtering. Built-in scopes cover Adminizer's own modules; projects can add custom scopes through `system.internalModelAccess`. + +```ts +const config: AdminizerConfig = { + routePrefix: "/admin", + system: { + internalModelAccess: { + "my-module": ["User", "Filter"] + } + } +}; +``` + +Usage: + +```ts +const filters = await adminizer.modelHandler + .internal("my-module") + .get("Filter") + .find({ where: { modelName: "Example" } }); +``` + +Use this only for trusted system-level code. Normal user-facing operations should continue to use model methods with `DataAccessor`. diff --git a/docs/Configuration/InlineEditing.md b/docs/Configuration/InlineEditing.md new file mode 100644 index 00000000..fd74ec34 --- /dev/null +++ b/docs/Configuration/InlineEditing.md @@ -0,0 +1,197 @@ +# Inline Editing in List View + +Adminizer supports inline editing of fields directly in the list view. This allows administrators to quickly update values without navigating to the edit form. + +## Overview + +When inline editing is enabled for a field, users can double-click on a cell value in the table to edit it directly. Changes are saved via an API call and the table updates instantly without a full page reload. + +## Enabling Inline Editing + +To enable inline editing for a field, set the `inlineEditable` flag in the field configuration: + +```ts +models: { + Example: { + title: 'Example', + model: 'example', + fields: { + title: { + title: 'Title', + type: 'string', + inlineEditable: true + }, + isActive: { + title: 'Active', + type: 'boolean', + inlineEditable: true + }, + price: { + title: 'Price', + type: 'number', + inlineEditable: true, + inlineValidation: { + min: 0, + max: 99999 + } + } + } + } +} +``` + +## Supported Field Types + +Inline editing is only available for the following field types: + +| Type | Control | +|------|---------| +| `string` | Text input | +| `integer` | Number input | +| `number` | Number input | +| `float` | Number input | +| `email` | Email input | +| `range` | Number input with min/max | +| `boolean` | Checkbox | + +If `inlineEditable: true` is set on an unsupported field type (e.g., `text`, `date`, `json`), the flag is **ignored** and the field remains read-only. + +## Conditions for Editing + +A field is editable in the list view only when **all** of the following conditions are met: + +| Condition | Description | +|-----------|-------------| +| `inlineEditable: true` | The flag must be explicitly set | +| No `displayModifier` | Fields with `displayModifier` are always read-only (priority) | +| Supported field type | The field type must be in the allowed list above | + +## Validation + +You can define validation rules for inline editing using the `inlineValidation` option: + +```ts +fieldName: { + title: 'Field Name', + type: 'string', + inlineEditable: true, + inlineValidation: { + minLength: 3, + maxLength: 100, + pattern: '^[a-zA-Z]+$', + validate: (value) => value.trim().length > 0 || 'Value cannot be empty' + } +} +``` + +### Validation Options + +| Option | Description | Applicable Types | +|--------|-------------|------------------| +| `minLength` | Minimum string length | `string`, `email` | +| `maxLength` | Maximum string length | `string`, `email` | +| `min` | Minimum value | `integer`, `number`, `float`, `range` | +| `max` | Maximum value | `integer`, `number`, `float`, `range` | +| `pattern` | Regex pattern for validation | `string`, `email` | +| `validate` | Custom validation function | All types | + +## User Interaction + +### Text and Number Fields + +1. **Double-click** on the cell value to start editing +2. An input field appears with the current value selected +3. Press **Enter** or click outside to save +4. Press **Escape** to cancel + +### Boolean Fields + +1. **Double-click** on the cell to start editing +2. A checkbox appears +3. Toggle the checkbox to change the value +4. Click outside the checkbox to save +5. Press **Escape** to cancel + +## API Endpoint + +Inline editing uses the following API endpoint: + +``` +PATCH /adminizer/model/:name/inline/:id +``` + +**Request body:** +```json +{ + "field": "fieldName", + "value": "newValue" +} +``` + +**Response:** +```json +{ + "success": true, + "data": { + "id": "record-id", + "field": "fieldName", + "value": "newValue" + } +} +``` + +## Example Configuration + +```ts +import { AdminpanelConfig } from "adminizer"; + +const config: AdminpanelConfig = { + routePrefix: "/admin", + models: { + Product: { + title: 'Products', + model: 'product', + fields: { + name: { + title: 'Name', + type: 'string', + inlineEditable: true, + inlineValidation: { + minLength: 2, + maxLength: 200 + } + }, + price: { + title: 'Price', + type: 'number', + inlineEditable: true, + inlineValidation: { + min: 0, + max: 999999 + } + }, + active: { + title: 'Active', + type: 'boolean', + inlineEditable: true + }, + email: { + title: 'Contact Email', + type: 'email', + inlineEditable: true, + inlineValidation: { + pattern: '^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$' + } + }, + // This field has displayModifier — inlineEditable will be ignored + displayName: { + title: 'Display Name', + type: 'string', + inlineEditable: true, // ← Ignored due to displayModifier + displayModifier: (v) => v?.toUpperCase() + } + } + } + } +}; +``` diff --git a/docs/Configuration/Localization.md b/docs/Configuration/Localization.md index df7eda43..f5c58b1d 100644 --- a/docs/Configuration/Localization.md +++ b/docs/Configuration/Localization.md @@ -2,20 +2,22 @@ Adminizer supports interface translations using the `translation` block in the configuration. -```js -module.exports.adminpanel = { +```ts +const config = { translation: { locales: ['en', 'de'], - path: 'config/locales', + directory: 'config/locales', defaultLocale: 'en' } }; ``` * `locales` – list of available locales -* `path` – relative path to translation files +* `directory` – relative path to translation files * `defaultLocale` – locale used when none is specified +`path` is still accepted for older configs, but is deprecated. Use `directory` for new projects. + In Inertia mode, interface strings are translated on the server through `req.i18n.__(...)` before props are sent to React components. This includes shared UI props such as sidebar navbar item titles and section labels. ## Shared UI keys @@ -27,7 +29,9 @@ If you localize Adminizer UI through translation dictionaries, make sure the loc * `Visible` * `First`, `Last`, `Previous`, `Next` -These keys are passed via shared `uiMessages` in Inertia mode and are used by menu/user controls and pagination UI. +These keys are passed via shared `i18n.common` in Inertia mode and are used by menu/user controls and pagination UI. + +For page-specific dictionaries, Adminizer uses `i18nPage` props, while reusable/common labels stay in `i18n.common`. ## Locale parity diff --git a/docs/Configuration/Models.md b/docs/Configuration/Models.md index ad751921..434f2b00 100644 --- a/docs/Configuration/Models.md +++ b/docs/Configuration/Models.md @@ -1,19 +1,189 @@ # System Models -Adminizer ships with several built‑in models used for authentication, media management and navigation. These definitions reside in `src/models` and can be automatically registered for **Waterline** and **Sequelize** ORMs by calling `registerSystemModels()` on the corresponding adapter. +Adminizer does not create or synchronize ORM models. The host application owns ORM initialization and must register all Adminizer system models before calling `adminizer.init()`. -The provided models are: +Adminizer core uses canonical system model names without project-specific suffixes: -- `UserAP` -- `GroupAP` -- `MediaManagerAP` -- `MediaManagerAssociationsAP` -- `MediaManagerMetaAP` -- `NavigationAP` +- `User` +- `Group` +- `Filter` +- `FilterColumn` +- `HistoryActions` +- `Notification` +- `UserNotification` -They can be created and queried like any other models once registered. +During startup Adminizer obtains these models from the adapter selected by `config.system.defaultORM` and validates their fields, primary keys, and required associations. Extra fields are allowed. Startup fails immediately when a model is missing or does not satisfy the system contract. -When using Sequelize the adapter generates explicit foreign keys using the -`Id` pattern. This prevents naming collisions between attributes and -associations when models reference themselves or each other. +## Host Model Names +The ORM model name does not have to match the Adminizer canonical name. If a project already has host models named `UserAP`, `GroupAP`, or even custom names such as `Cats`, pass the mapping to the adapter: + +```ts +const adapter = new SequelizeAdapter(sequelize, { + systemModels: { + User: "UserAP", + Group: "GroupAP", + Filter: "FilterAP", + FilterColumn: "FilterColumnAP", + HistoryActions: "HistoryActionsAP", + Notification: "NotificationAP", + UserNotification: "UserNotificationAP", + }, +}); +``` + +Adminizer will use `User`, `Group`, and the other canonical names internally, while validating and querying the mapped host ORM models. + +## Resource Names and Host Models + +The key in `config.models` is the canonical **Adminizer resource name**. It controls the CRUD URL, model configuration, permission tokens, and `DataAccessor` behavior. The `model` property is only the physical ORM host model name. + +This lets a project use host models whose names would otherwise conflict with Adminizer system resources: + +```ts +const config = { + models: { + Customer: { + model: "User", // project Waterline/ORM model + title: "Customers", + }, + CatalogGroup: { + model: "Group", // project Waterline/ORM model + title: "Catalog groups", + }, + city: false, // registered for associations but hidden from the CRUD menu + }, +}; +``` + +Together with the system binding above, the resulting mapping is: + +| Adminizer resource | Host model | Example URL | Permission token | +| --- | --- | --- | --- | +| `User` | `UserAP` | `/model/User` | `read-user-model` | +| `Group` | `GroupAP` | `/model/Group` | `read-group-model` | +| `Customer` | `User` | `/model/Customer` | `read-customer-model` | +| `CatalogGroup` | `Group` | `/model/CatalogGroup` | `read-cataloggroup-model` | + +Use the resource name in Adminizer code: + +```ts +const systemUsers = adminizer.modelHandler.getResource("User"); +const customers = adminizer.modelHandler.getResource("Customer"); +``` + +Use `getByHostModel()` only when adapter metadata supplies a physical ORM model name, such as an association target: + +```ts +const customerModel = adminizer.modelHandler.getByHostModel("User"); +``` + +Resource names remain case-insensitive for compatibility, so do not register resource names that differ only by letter case. + +### Multiple Adminizer Resources for One Host Model + +You can expose the same host ORM model through multiple Adminizer resources when they need different fields, filters, URLs, or permissions. Mark exactly one resource as `primary: true`: + +```ts +models: { + Customer: { + model: "User", + primary: true, + title: "Customers", + }, + Customer2: { + model: "User", + title: "Customer audit view", + fields: { + email: {visible: false}, + }, + }, +} +``` + +Both resources have independent CRUD URLs and permission tokens. The primary resource is used only when an association identifies its target by the host model name (`User` in this example). Startup fails if a shared host model has no primary resource or more than one primary resource. + +### Deprecated Host-name Lookup + +For existing integrations, a mapped system host name such as `UserAP` or `GroupAP` still resolves through `modelHandler.model.get()`. This is a deprecated compatibility alias: + +```ts +// Deprecated: compatibility only. +adminizer.modelHandler.model.get("UserAP"); + +// Preferred canonical API. +adminizer.modelHandler.getResource("User"); +``` + +Do not use host model names as aliases for project resources. In the example above, `modelHandler.model.get("User")` refers to the canonical system resource; use `getResource("Customer")` for the project model. + +If your host ORM models already use canonical names, no mapping is required: + +```ts +const adapter = new SequelizeAdapter(sequelize); +``` + +## Sequelize + +Sequelize is the primary and recommended ORM. Register system models in the host Sequelize instance, define their associations, synchronize or migrate the database, and then construct Adminizer with the adapter: + +```ts +registerSequelizeSystemModels(sequelize); +await sequelize.sync(); + +const adminizer = new Adminizer([ + new SequelizeAdapter(sequelize, { + systemModels: { + User: "UserAP", + Group: "GroupAP", + Filter: "FilterAP", + FilterColumn: "FilterColumnAP", + HistoryActions: "HistoryActionsAP", + Notification: "NotificationAP", + UserNotification: "UserNotificationAP", + }, + }), +]); + +await adminizer.init(config); +``` + +The fixture helper `registerSequelizeSystemModels` still registers host models with the `AP` suffix. That is fixture-specific naming, not a core requirement. + +App-owned Sequelize models may be installed later, immediately before `appManager.enable(app)`. + +## App-Owned Models + +Models registered by apps are not part of the core system contract. `AppManager` only attaches an existing ORM model through `ctx.model()`; it does not define tables or synchronize schemas. + +The fixture media manager is an example. Its `MediaManagerAP`, `MediaManagerMetaAP`, and `MediaManagerAssociationsAP` models are installed by `fixture/apps/media-manager/MediaManagerModels.ts` after `adminizer.init()` and before the media manager app is enabled. Adminizer does not require or create these models unless that app is used. + +## TypeORM + +TypeORM support is experimental. System entities must be included in `DataSource.entities` before initialization: + +```ts +const dataSource = new DataSource({ + // ... + entities: [ + ...typeOrmSystemModels, + ...projectEntities, + ], +}); + +await dataSource.initialize(); + +const adapter = new TypeOrmAdapter(dataSource, { + systemModels: { + User: "UserAP", + Group: "GroupAP", + Filter: "FilterAP", + FilterColumn: "FilterColumnAP", + HistoryActions: "HistoryActionsAP", + Notification: "NotificationAP", + UserNotification: "UserNotificationAP", + }, +}); +``` + +App entities must also be included before `dataSource.initialize()`. Dynamic installation of app models into an initialized TypeORM `DataSource` is not supported. Apps without models continue to work, and apps with models work only when their entities were registered before initialization. diff --git a/docs/Configuration/Widgets.md b/docs/Configuration/Widgets.md index 493bd1f4..cd72ba4e 100644 --- a/docs/Configuration/Widgets.md +++ b/docs/Configuration/Widgets.md @@ -1,6 +1,6 @@ This is an example of customizing a custom widget (CustomOne) in a React application that is built using Vite. -### **1. Custom Widget Definition (`fixture/test-widgets/Custom.ts`)** +### **1. Custom Widget Definition (`fixture/widgets/Custom.ts`)** - This is a widget class (`CustomOne`) that extends a base widget class (`CustomBase`). - It defines metadata for the widget, such as: - `id`, `name`, `description`, and `department` (e.g., `"test"`) @@ -10,7 +10,7 @@ This is an example of customizing a custom widget (CustomOne) in a React applica - It specifies JavaScript paths for **development** (direct `.tsx` file) and **production** (a compiled `.es.js` file). - The widget is initialized with a `routePrefix` (likely a base URL for asset loading). -### **2. React Component (`modules/test/ComponentB.tsx`)** +### **2. React Component** - This is the actual UI implementation of the widget. - It displays: - A **message** (passed as a prop) @@ -18,20 +18,20 @@ This is an example of customizing a custom widget (CustomOne) in a React applica - The **current counter value** (managed via React’s `useState`) - The button uses basic styling (similar to a styled button component). -### **3. Vite Build Configuration (`modules/test/vite.config.module.ts`)** +### **3. Vite Build Configuration** - Configures the build process for the React component. - **Key settings:** - Builds the component as an **ES module** (`lib` mode). - - Outputs the file as `ComponentB.es.js`. + - Outputs the file as `CustomWidget.es.js`. - Treats `react` and `react-dom` as **external dependencies** (they won’t be bundled; expected to be loaded separately). - Uses path aliases (e.g., `@` points to a shared JS directory). - Supports both **development** (direct `.tsx` usage) and **production** (optimized `.es.js` file). ### **How It All Works Together** 1. **`CustomOne`** defines the widget’s metadata and where its JavaScript lives. -2. **`ComponentB`** provides the interactive UI (a counter button with a message). -3. **Vite** compiles the React component into a standalone ES module (`ComponentB.es.js`). +2. **`CustomWidget`** provides the interactive UI (a counter button with a message). +3. **Vite** compiles the React component into a standalone ES module (`CustomWidget.es.js`). - In **development**, it loads the `.tsx` file directly. - - In **production**, it loads the optimized `.es.js` file from a CDN-like path (`/assets/modules/`). + - In **production**, it should load the optimized `.es.js` file from a registered asset URL. Prefer `AssetHandler`/app asset routes for new code instead of manually copied static asset paths. This setup allows the widget to be reusable, dynamically loaded, and integrated into a larger application. diff --git a/docs/Controls.md b/docs/Controls.md index aa95ac3a..efca8843 100644 --- a/docs/Controls.md +++ b/docs/Controls.md @@ -1,328 +1,269 @@ [Back](index.md) + # Controls + ## Overview -AbstractControls is an abstract base class that defines the common interface for control components in the Adminizer system. It provides the foundational structure that all specific control types must implement. -## Type Definitions -### `ControlType` -```typescript -type ControlType = 'wysiwyg' | 'jsonEditor' | 'geoJson' | 'markdown' | 'table' | 'codeEditor' -``` -Defines the available types of controls that can be implemented. -### `Path` -```typescript -interface Path { - jsPath: { - dev: string - production: string - }, - cssPath: string -} -``` -Defines the path structure for JavaScript and CSS assets, with separate paths for development and production environments. -### `Config` -```typescript -type Config = Record -``` -Represents a configuration object that can contain various types of values. -## AbstractControls Class -### Properties -`name` -```typescript -public abstract readonly name: string -``` -The unique name identifier for the control (abstract, must be implemented). +Controls are dynamically loaded form components used for WYSIWYG, Markdown, table, JSON, code, and GeoJSON fields. -`type` -```typescript -public abstract readonly type: ControlType -``` -The type of the control (abstract, must be implemented). +Adminizer has two control sources: -`path` -```typescript -public abstract readonly path: Path -``` -The asset paths for the control (abstract, must be implemented). +- built-in controls registered by the core during initialization; +- app-owned controls registered through `AppSetupContext.control()`. -`config` -```typescript -public abstract readonly config: Config -``` -The configuration object for the control (abstract, must be implemented). +Control JavaScript and CSS are not loaded with the main Adminizer bundle. The field renderer imports them when a control field is rendered on an add or edit page. Visiting the dashboard does not load control bundles. -`routPrefix` -```typescript -public readonly routPrefix: string -``` -The route prefix inherited from the Adminizer instance. -### Constructor -```typescript -protected constructor(adminizer: Adminizer) -``` -Initializes the control with a reference to the Adminizer instance and sets the route prefix. - -**Parameters:** -* `adminizer`: The Adminizer instance that owns this control -### Methods -`getConfig()` -```typescript -public abstract getConfig(): Config | undefined -``` -Retrieves the control's configuration (abstract, must be implemented). +## Control Contract -**Returns**: The configuration object or undefined -`getJsPath()` -```typescript -public abstract getJsPath(): string | undefined -``` -Gets the appropriate JavaScript path based on environment (abstract, must be implemented). +The public control types are exported from `adminizer`: -**Returns**: The JavaScript path string or undefined -`getCssPath()` -```typescript -public abstract getCssPath(): string | undefined -``` -Gets the CSS path (abstract, must be implemented). +```ts +export type ControlType = + | "wysiwyg" + | "jsonEditor" + | "geoJson" + | "markdown" + | "table" + | "codeEditor"; -**Returns**: The CSS path string or undefined -`getName()` -```typescript -public abstract getName(): string -``` -Gets the control name (abstract, must be implemented). - -**Returns**: The control name string -## Usage -To create a custom control, extend this abstract class and implement all abstract members: -```typescript -class MyCustomControl extends AbstractControls { - public readonly name = "my-control"; - public readonly type: ControlType = 'wysiwyg'; - public readonly path = { - jsPath: { dev: '/dev/path.js', production: '/prod/path.js' }, - cssPath: '/styles.css' - }; - public readonly config = { /* ... */ }; +export type Config = + Record; - constructor(adminizer: Adminizer) { - super(adminizer); - } +export interface Control { + readonly name: string; + readonly type: ControlType; - // Implement all abstract methods... + getConfig(): Config | undefined; + getJsPath(): string | undefined; + getCssPath(): string | undefined; + getName(): string; } ``` -# ControlsHandler -## Overview -The `ControlsHandler` class provides a centralized management system for control components in the Adminizer framework. It organizes controls by type and name, allowing for easy retrieval, addition, and removal of controls. -## Class Structure -### Properties -`controls` -```typescript -private controls = new Map>() -``` -Internal storage structure that maintains controls in a nested Map: -* Outer Map: Key is `ControlType` -* Inner Map: Key is control name, value is the `AbstractControls` instance -### Methods -`add(control: AbstractControls): void` - -Adds a new control to the handler. - -**Parameters:** -* `control`: The control instance to add (must extend `AbstractControls`) -* -**Behavior**: - -* Creates a new type group if the control type doesn't exist -* Throws an error if a control with the same name already exists -* Adds the control to the appropriate type group - -**Example:** -```typescript -handler.add(new MyControl(adminizer)); -``` -`get(type: T, name: string): AbstractControls | undefined` -Retrieves a specific control by its type and name. +There is no `AbstractControls` base class. Built-in controls implement `Control` directly and receive only `routePrefix` in their constructors. -**Parameters:** +`Path` is an optional helper type used by the built-in implementations: -* `type`: The type of control to retrieve -* `name`: The name of the specific control +```ts +export interface Path { + jsPath: { + dev: string; + production: string; + }; + cssPath: string; +} +``` -**Returns**: The control instance or undefined if not found +## Built-In Controls -**Example:** -```typescript -const editor = handler.get('wysiwyg', 'main-editor'); -``` -`getByType(type: T): AbstractControls[]` +Core registers these defaults: -Retrieves all controls of a specific type. +| Type | Default name | +|---|---| +| `wysiwyg` | `ckeditor` | +| `markdown` | `toast-ui` | +| `table` | `handsontable` | +| `jsonEditor` | `jsoneditor` | +| `codeEditor` | `monaco` | +| `geoJson` | `leaflet` | -**Parameters:** -* `type`: The type of controls to retrieve +Each built-in control is compiled to a separate ES module under `/assets/controls`. Production styles are also emitted separately. In development, Vite serves the TSX entry directly. -**Returns:** An array of control instances (empty array if none found) +Some built-in controls are also exposed through `window.JSComponents`. Those exports are lazy proxies: importing the Adminizer UI globals does not immediately download the control implementation. -**Example:** -```typescript -const allEditors = handler.getByType('wysiwyg'); -``` -`remove(type: ControlType, name: string): boolean` +## App-Owned Controls -Removes a control from the handler. +Custom controls should be implemented as Adminizer apps. The app registers the component and optional stylesheet with `ctx.control()`: -**Parameters:** -* `type`: The type of control to remove -* `name`: The name of the specific control +```ts +import path from "path"; +import { + AbstractAdminizerApp, + type AppSetupContext, + type Config, +} from "adminizer"; -**Returns:** `true` if the control was found and removed, `false` otherwise +interface MyEditorAppConfig { + config: Config; + componentFile: string; + componentDevUrl: string; + stylesheetFile: string; + stylesheetDevUrl: string; +} -**Example:** -```typescript -const wasRemoved = handler.remove('wysiwyg', 'old-editor'); +export class MyEditorApp extends AbstractAdminizerApp { + readonly name = "my-editor"; + readonly version = "1.0.0"; + declare readonly config: MyEditorAppConfig; + + constructor(config: Partial = {}) { + super(); + this.config = { + config: {}, + componentFile: path.resolve(import.meta.dirname, "my-editor.es.js"), + componentDevUrl: "/apps/my-editor/my-editor.tsx", + stylesheetFile: path.resolve(import.meta.dirname, "my-editor.css"), + stylesheetDevUrl: "/apps/my-editor/my-editor.css", + ...config, + }; + } + + setup(ctx: AppSetupContext): void { + ctx.control({ + type: "wysiwyg", + name: "my-editor", + config: this.config.config, + component: { + id: "editor", + filePath: this.config.componentFile, + devUrl: this.config.componentDevUrl, + }, + stylesheet: { + id: "editor-css", + filePath: this.config.stylesheetFile, + devUrl: this.config.stylesheetDevUrl, + }, + }); + } +} ``` -`getAll(): Record` -Retrieves all controls grouped by type. +`component` is required. `stylesheet` is optional. Both use the same `AppAsset` contract as `ctx.asset()`: + +```ts +interface AppAsset { + id: string; + filePath: string; + route?: string; + devUrl?: string; +} +``` -**Returns:** An object where keys are control types and values are arrays of controls +In development, `devUrl` is returned directly. In production, Adminizer creates app-owned asset routes under `routePrefix`. -**Example:** -```typescript -const allControls = handler.getAll(); -// Returns: { wysiwyg: [...], markdown: [...], ... } +Enable the app after Adminizer initialization: + +```ts +await adminizer.init(adminpanelConfig); +await adminizer.appManager.enable(new MyEditorApp()); ``` -`collectAndGenerateStyleLinks(): string[]` -Collects all CSS paths from registered controls. -Returns: An array of CSS paths +Disabling or unregistering the app removes its control registration and asset routes: -**Example:** -```typescript -const stylesheets = handler.collectAndGenerateStyleLinks(); +```ts +await adminizer.appManager.disable("my-editor"); ``` -### Usage Example -```typescript -// Initialize handler -const handler = new ControlsHandler(); -// Add controls -handler.add(new WysiwygControl(adminizer)); -handler.add(new MarkdownControl(adminizer)); +## Frontend Component -// Get controls -const mainEditor = handler.get('wysiwyg', 'main-editor'); -const allMarkdown = handler.getByType('markdown'); +A control module must export its React component as the default export: -// Get all stylesheets -const styles = handler.collectAndGenerateStyleLinks(); -``` -## Redefining basic controls -Create a new `class`, for example: -```typescript -export class ReactQuill extends AbstractControls { - readonly name: string = 'react-quill'; - readonly type: ControlType = 'wysiwyg'; - readonly path: Path = { - cssPath: `${this.routPrefix}/assets/modules/react-quill-editor.css`, - jsPath: - { - dev: "/modules/controls/wysiwyg/react-quill-editor.tsx", - production: `${this.routPrefix}/assets/modules/react-quill-editor.es.js` - } - } - readonly config: Config = {}; - - getConfig(): Config { - return this.config; - } - - constructor(adminizer: Adminizer) { - super(adminizer); - } - - getJsPath(): string { - if (process.env.VITE_ENV === 'dev') { - return this.path.jsPath.dev; - } else { - return this.path.jsPath.production - } - } - - getCssPath(): string { - return this.path.cssPath - } - - getName(): string { - return this.name - } +```tsx +interface ControlProps { + options?: Record; + initialValue: unknown; + onChange: (value: unknown) => void; + name: string; + disabled?: boolean; } -``` -Then add it via `ControlsHandler`, like this: -```typescript -// add custom control wysiwyg -adminizer.emitter.on('adminizer:loaded', () => { - adminizer.controlsHandler.add(new ReactQuill(adminizer)) -}) -``` -After that, you can use in the configuration: -```js - editor: { - title: 'Editor', - type: 'wysiwyg', - options: { - name: 'react-quill', - } +export default function MyEditor({ + initialValue, + onChange, + disabled, +}: ControlProps) { + // Render the editor and call onChange with its current value. } ``` -See the implementation example here `fixture` & here `modules/controls/wysiwyg` -To build the module, you will need custom `vite.config` -```typescript -import {defineConfig} from 'vite'; -import react from '@vitejs/plugin-react'; -import path from 'path'; +Adminizer loads the stylesheet and component module in parallel. Loaded stylesheets are cached and are not appended more than once. + +## Vite Build + +Build the frontend component as an ES module. Externalize React so the control uses the Adminizer runtime copy: + +```ts +import {defineConfig} from "vite"; +import react from "@vitejs/plugin-react"; import {viteExternalsPlugin} from "vite-plugin-externals"; +import path from "path"; export default defineConfig({ - define: { - 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production'), - }, - plugins: [ - react(), - viteExternalsPlugin({ - react: 'React', - 'react-dom': 'ReactDOM', - }), - ], - build: { - outDir: path.resolve(import.meta.dirname, ''), - emptyOutDir: false, - lib: { - entry: path.resolve(import.meta.dirname, 'react-quill-editor.tsx'), - name: 'ComponentB', - formats: ['es'], - fileName: (format) => `react-quill-editor.${format}.js`, - }, - rollupOptions: { - output: { - assetFileNames: ({ names }) => { - if (names && names[0].endsWith('.css')) { - return 'react-quill-editor.css'; - } - return '[name]-[hash][extname]'; - }, - }, - }, - }, - resolve: { - alias: { - '@': path.resolve(import.meta.dirname, '../../../src/assets/js'), - }, + define: { + "process.env.NODE_ENV": JSON.stringify( + process.env.NODE_ENV || "production" + ), + }, + plugins: [ + react(), + viteExternalsPlugin({ + react: "React", + "react-dom": "ReactDOM", + }), + ], + build: { + outDir: import.meta.dirname, + emptyOutDir: false, + lib: { + entry: path.resolve(import.meta.dirname, "my-editor.tsx"), + name: "MyEditor", + formats: ["es"], + fileName: (format) => `my-editor.${format}.js`, + cssFileName: "my-editor", }, + }, }); ``` + +The fixture React-Quill example is in `fixture/apps/quill-editor`. All fixture app frontend modules, including Quill, are built together: + +```bash +npm run build:apps +``` + +## Field Configuration + +Reference the registered control by name: + +```ts +editor: { + title: "Editor", + type: "wysiwyg", + options: { + name: "my-editor", + config: { + theme: "snow", + }, + }, +} +``` + +The control's base config is merged with the field config. Field values take precedence. + +## Missing Or Disabled Controls + +If a configured control is not registered, for example because its app is disabled, Adminizer: + +1. logs one server warning per model field and missing control; +2. falls back to the built-in default for that control type; +3. does not pass the missing control's field config to the fallback control. + +The third rule prevents options intended for one editor from being applied to an incompatible editor. + +If the built-in default is also missing, Adminizer throws a configuration error. + +## ControlsHandler + +`ControlsHandler` stores controls by type and name. Its API is: + +```ts +add(control: Control): void; +get(type: ControlType, name: string): Control | undefined; +getByType(type: ControlType): Control[]; +remove(type: ControlType, name: string): boolean; +getAll(): Record; +``` + +Adding a duplicate name within the same type throws an error. + +Application modules should normally use `ctx.control()` instead of calling `adminizer.controlsHandler.add()` directly. App registration provides ownership, asset serving, rollback when enable fails, and cleanup during disable or unregister. diff --git a/docs/Dashboard.md b/docs/Dashboard.md index bb6d1570..a41e5132 100644 --- a/docs/Dashboard.md +++ b/docs/Dashboard.md @@ -48,18 +48,17 @@ module.exports.adminpanel = { * link [LinkBase] - provide link logic in widget * custom [CustomBase] - mount JS/html widget into dashboard widget -Widgets have magic on loading sails project such as model or service. This function work on -`sails.config.adminpanel.autoloadWidgetsPath: string`. A widget class is created from an abstract base and passed to `WidgetHandler.add`. +Widgets are registered on the Adminizer instance through `adminizer.widgetHandler.add(widget)`. The dashboard config can also define `autoloadWidgetsPath` for project-specific widget loading. > ⚠️ Any new widget type must be added to `WidgetType` inside `/lib/widgets/widgetHandller.ts`. Within this handler you also need to implement the logic for the new type in the `getAll` method. -> ⚠️ You can access widget handler by call method of admin panel `sails.hooks.adminpanel.getWidgetHandler()` +> ⚠️ You can access the widget handler as `adminizer.widgetHandler`. - > ⚠️ You can add a widget by calling `sails.hooks.adminpanel.addDashboardWidget(widget: WidgetType)`. + > ⚠️ You can add a widget by calling `adminizer.widgetHandler.add(widget)`. WidgetBase instance has this props: -- `ID`: string, required +- `id`: string, required - `name`: string, - `description`: string, - `icon`: string | null, default = null @@ -118,11 +117,11 @@ public abstract readonly widgetType: Next Handling Custom Widgets. Add logic to `getAll` method: else if (widget instanceof CustomBase) { - if (AccessRightsHelper.havePermission(`widget-${widget.ID}`, user)) { + if (adminizer.accessRightsHelper.hasPermission(`widget-${widget.id}`, user)) { widgets.push({ - id: `${widget.ID}_${id_key}`, + id: `${widget.id}_${id_key}`, type: widget.widgetType, - api: `${config.routePrefix}/widgets-custom/${widget.ID}`, + api: `${config.routePrefix}/widgets-custom/${widget.id}`, description: widget.description, icon: widget.icon, name: widget.name, @@ -134,7 +133,7 @@ Next Handling Custom Widgets. Add logic to `getAll` method: Then we`ll create Instance of this widget type class CustomOne extends CustomBase { - readonly ID: string = 'site_custom'; + readonly id: string = 'site_custom'; readonly department: string = 'test'; readonly description: string = 'Widget Custom One'; readonly icon: string = 'dog'; @@ -146,13 +145,13 @@ class CustomOne extends CustomBase { And export it as function which create our instances -const setCustomsWidgets = () => { - WidgetHandler.add(new CustomOne()) +const setCustomsWidgets = (adminizer) => { + adminizer.widgetHandler.add(new CustomOne()) } `module.exports = { setCustomsWidgets }` -Then call `setCustomsWidgets` inside `bootstrap.js` +Then call `setCustomsWidgets(adminizer)` after Adminizer is initialized. ### customizable and programmable promotion diff --git a/docs/Export.md b/docs/Export.md new file mode 100644 index 00000000..c481f35e --- /dev/null +++ b/docs/Export.md @@ -0,0 +1,168 @@ +# Export Data + +Adminizer supports exporting filtered data from model list views in three formats: **JSON**, **CSV**, and **XLSX**. + +## Overview + +The export feature allows users to download all records (respecting the currently active filter) from any model list page. Export is available without pagination limits. + +## Supported Formats + +| Format | Description | Use Case | +|--------|-------------|----------| +| **JSON** | Raw JSON array of objects | API integration, data backup, programmatic processing | +| **CSV** | Comma-separated values with UTF-8 BOM | Opening in Excel, Google Sheets, data analysis | +| **XLSX** | Native Excel spreadsheet with auto-sized columns | Business reports, presentations | + +## Backend API + +### Endpoint + +``` +POST /adminizer/model/:modelName/export +``` + +### Request Body + +```json +{ + "format": "json|csv|xlsx", + "filterId": "optional-filter-id" +} +``` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `format` | string | No | Export format: `json`, `csv`, or `xlsx`. Defaults to `json`. | +| `filterId` | string | No | ID of a saved filter to apply. If omitted, exports all records. Use `temporary` for session-based temporary filters. | +| `selectedFields` | string[] | No | Array of field names to include in export. If omitted, uses filter's column config or all fields. | + +### Response + +- **Success:** File download with appropriate `Content-Type` and `Content-Disposition` headers +- **400:** Invalid format or missing required data +- **401:** Unauthorized (if auth is enabled) +- **403:** Forbidden (insufficient permissions) +- **404:** Model not found +- **500:** Export failed (e.g., xlsx library not installed) + +### Response Headers + +``` +Content-Type: application/json (for JSON) +Content-Type: text/csv; charset=utf-8 (for CSV) +Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet (for XLSX) +Content-Disposition: attachment; filename="modelname_export_1234567890.json" +``` + +## Frontend Usage + +The export button appears automatically in the table toolbar when the page loads. + +### How to Export + +1. Navigate to any model list page +2. (Optional) Apply a saved filter to narrow down results +3. Click the **Экспорт** (Export) button in the toolbar +4. Select the desired format: JSON, CSV, or XLSX +5. The file downloads automatically + +### Filter Integration + +- If a **saved filter** is active, only filtered records are exported +- If a **temporary filter** is applied, it is used for the export +- If **no filter** is active, all records are exported + +## Implementation Details + +### Controller: `src/controllers/exportData.ts` + +The export controller: +1. Validates the user has `read-{modelName}-model` permission +2. Loads the saved filter (if `filterId` is provided) +3. Converts datetime conditions from HTML5 format to UTC +4. Applies custom column configuration from filter +5. Converts filter conditions through `QueryBuilder` into internal `QueryCriteria` +6. Executes a query with no pagination (large limit) +7. Selects only real database columns at SQL level; relation/display fields are loaded and formatted after records are fetched +8. Formats data based on field types (relationships, booleans, dates, JSON) +9. Generates the file and sends it as a download + +### Route Registration: `src/system/Router.ts` + +```typescript +import _exportData from "../controllers/exportData"; + +adminizer.app.post(`${baseRoute}/export`, adminizer.policyManager.bindPolicies(policies, _exportData)); +``` + +### Frontend: `src/assets/js/components/list-table/table-toolbar.tsx` + +The export dropdown: +- Uses Adminizer HTTP client (`adminApi`) for export requests and creates a `Blob` for download +- Reads `filterId` from URL to preserve filter state +- Extracts filename from `Content-Disposition` header +- Shows toast notifications for success/error + +## Dependencies + +| Package | Purpose | Required | +|---------|---------|----------| +| `xlsx` | XLSX file generation | Yes (for XLSX export) | + +Install xlsx: +```bash +npm install xlsx +``` + +## CSV Encoding + +CSV files include a **UTF-8 BOM** (`\uFEFF`) prefix to ensure proper character encoding when opened in Microsoft Excel. + +## Data Formatting + +The export applies human-readable transformations: +- **Boolean:** `true` → `Yes`, `false` → `No` +- **Relationships:** Display field (title/name/id) is used +- **Has-Many:** Comma-separated list of display values +- **JSON:** Pretty-printed with indentation (2 spaces) +- **Dates:** ISO 8601 format (`YYYY-MM-DDTHH:mm:ss.sssZ`) +- **Arrays:** Comma-separated values + +## Security + +- Export respects the same **access rights** as the list view +- Only users with `read-{modelName}-model` permission can export +- Filter access control is enforced (private filters only accessible by owner) +- Datetime conditions are validated and converted to prevent injection + +## Extending + +To add a new export format: + +1. Add the format to the `ExportFormat` type in `exportData.ts` +2. Create a new `send{Format}Export` function +3. Add a case to the switch statement in the main controller +4. Add a menu item in `table-toolbar.tsx` + +Example for PDF export: + +```typescript +// In exportData.ts +async function sendPdfExport(res: ResType, data: Record[], modelName: string) { + // Generate PDF... + return res.send(buffer); +} + +// In the switch +case 'pdf': + return sendPdfExport(res, exportData, entity.name); +``` + +```tsx +// In table-toolbar.tsx + handleExport('pdf')}> + PDF + PDF документ + +``` diff --git a/docs/FeedExport.md b/docs/FeedExport.md new file mode 100644 index 00000000..f90f05e5 --- /dev/null +++ b/docs/FeedExport.md @@ -0,0 +1,198 @@ +# Feed Export API + +Public API for exporting filtered data in JSON and XML (Atom) formats. It allows external systems to fetch filtered data by URL without an Adminizer UI session. + +## Overview + +Filters with enabled API access generate a unique `apiKey`, which can be used to fetch data in the following formats: + +- **JSON** for programmatic integrations +- **XML (Atom)** for RSS-like feeds, marketplaces, news aggregators, and similar consumers + +## Enabling API Access For A Filter + +1. Open the record list for the target model. +2. Click **Filters** and select an existing filter to edit. +3. Find the **API access (feed)** section at the bottom of the dialog. +4. Enable the toggle. +5. Click **Show** to view the feed links. +6. Copy the JSON or XML URL. + +### Key Generation + +- The first time API access is enabled, Adminizer automatically generates an `apiKey` (UUID v4). +- The **Regenerate** button creates a new key, and the previous link stops working. +- The key is stored in the database in the `Filter` model. + +## Endpoints + +### JSON Export + +```http +GET /adminizer/api/feed/{apiKey}.json +``` + +**Response:** + +```json +{ + "feed": { + "title": "Filter title", + "description": "Filter description", + "modelName": "Example", + "generatedAt": "2026-04-06T12:00:00.000Z", + "totalItems": 42, + "apiKey": "550e8400-e29b-41d4-a716-446655440000", + "items": [ + { + "id": 1, + "name": "Record 1", + "status": "active", + "createdAt": "2026-04-01T10:00:00.000Z" + } + ] + } +} +``` + +### XML (Atom) Export + +```http +GET /adminizer/api/feed/{apiKey}.xml +``` + +**Response:** + +```xml + + + /adminizer/api/feed/550e8400-e29b-41d4-a716-446655440000 + Filter title + Filter description + 2026-04-06T12:00:00.000Z + + Adminizer + + 1 + Record 1 + 2026-04-01T10:00:00.000Z + Record 1 + + + id1 + nameRecord 1 + + ]]> + + {"id":1,"name":"Record 1"} + + +``` + +## Usage Examples + +### curl + +```bash +# JSON +curl https://example.com/adminizer/api/feed/YOUR_API_KEY.json + +# XML +curl https://example.com/adminizer/api/feed/YOUR_API_KEY.xml +``` + +### JavaScript / Fetch + +```javascript +const response = await fetch('/adminizer/api/feed/YOUR_API_KEY.json'); +const data = await response.json(); +console.log(data.feed.items); +``` + +### Python + +```python +import requests + +url = "https://example.com/adminizer/api/feed/YOUR_API_KEY.json" +response = requests.get(url) +data = response.json() + +for item in data['feed']['items']: + print(item['name']) +``` + +### RSS Aggregator + +Add `https://example.com/adminizer/api/feed/YOUR_API_KEY.xml` to your RSS reader, such as Feedly or Inoreader. + +## Security + +- Access requires two keys: the filter `apiKey` and the user `userKey` (`User.apiKey`). +- The `apiKey` must belong to a private filter with API access enabled. +- The `userKey` is validated before the feed is generated. +- Global authorization must be enabled (`auth.enable`). +- If the filter key is compromised, regenerate the `apiKey` in the filter UI. +- If the user key is compromised, regenerate the `userKey`. + +## Errors + +| Code | Description | +|------|-------------| +| `400` | Invalid format. Only `json` and `xml` are supported. | +| `401` | `userKey` was not provided. | +| `403` | Authorization is disabled or `userKey` is invalid. | +| `404` | Filter was not found or API access is disabled. | +| `500` | Internal server error. | + +**Error example:** + +```json +{ + "error": "Filter not found or API access is disabled", + "message": "Check your apiKey or ensure API access is enabled for this filter" +} +``` + +## Architecture + +### Files + +| File | Description | +|------|-------------| +| `src/services/FeedService.ts` | Feed generation service (JSON + XML). | +| `src/controllers/feed.ts` | HTTP controller. | +| `src/system/Router.ts` | Route registration. | +| `src/controllers/filter-fields/savedFilters.ts` | `apiKey` generation on save. | +| `src/assets/js/components/list-table/filter-panel-save-dialog.tsx` | API management UI. | + +### Data Format + +Data is formatted the same way as in regular exports (`exportData.ts`): + +- Relations are rendered by title field. +- Dates use ISO 8601. +- Booleans are rendered as `"Yes"` or `"No"`. +- Arrays are joined with commas. +- JSON values are serialized to strings. + +Filters are executed through the same flow as list export: filter conditions are converted by `QueryBuilder` into internal `QueryCriteria`, and the ORM adapter translates the criteria into its query format. + +## Customization + +### Change The JSON Structure + +Edit the `generateJsonFeed()` method in `FeedService.ts`: + +```typescript +async generateJsonFeed(filter: Filter): Promise { + const { records, fields, modelName } = await this.fetchFilterData(filter); + // Custom logic + return { /* custom structure */ }; +} +``` + +### Change The XML Format + +Edit the `buildAtomXml()` method in `FeedService.ts`. diff --git a/docs/Filters.md b/docs/Filters.md new file mode 100644 index 00000000..fec5fb26 --- /dev/null +++ b/docs/Filters.md @@ -0,0 +1,327 @@ +# Filters in Adminizer + +This document describes the current filter implementation in the codebase (backend + frontend). + +## Overview + +The filter subsystem allows users to: + +- build and apply temporary filters; +- save personal/shared filters with metadata; +- store per-filter column order; +- expose private filters as JSON/XML feeds (with API keys); +- use relation and custom conditions through Adminizer's internal query language. + +Main files: + +- `src/models/Filter.ts` +- `src/models/FilterColumn.ts` +- `src/lib/filters/FilterService.ts` +- `src/lib/query-builder/QueryBuilder.ts` +- `src/interfaces/queryCriteria.ts` +- `src/controllers/filter-fields/*` +- `src/assets/js/components/list-table/filter-panel.tsx` + +## Data Models + +### `Filter` + +| Field | Type | Notes | +|---|---|---| +| `id` | `string` | UUID | +| `name` | `string` | Required | +| `description` | `string?` | Optional | +| `modelName` | `string` | Required | +| `conditions` | `FilterCondition[]` | JSON array | +| `sortField` | `string?` | Optional | +| `sortDirection` | `'ASC' \| 'DESC'?` | Optional | +| `visibility` | `'private' \| 'public' \| 'groups' \| 'system'` | Access mode | +| `ownerId` | `number` | Owner user id | +| `groupIds` | `number[]?` | Used when visibility is `groups` | +| `apiEnabled` | `boolean` | Feed enabled flag | +| `apiKey` | `string?` | Filter API key for feeds | +| `icon` | `string?` | UI icon | +| `color` | `string?` | UI color | +| `version` | `number` | Filter format version (`1`) | +| `createdAt` / `updatedAt` | `Date` | Auto timestamps | + +### `FilterColumn` + +`FilterColumn` stores one row per selected column: + +| Field | Type | Notes | +|---|---|---| +| `id` | `number` | Auto-increment | +| `filter` | `string \| Filter` | Relation to `Filter` | +| `fieldName` | `string` | Model field id | +| `order` | `number` | Display order | + +### `FilterCondition` + +```ts +interface FilterCondition { + id: string; + field?: string; + operator?: FilterOperator; + value?: any; + + logic?: 'AND' | 'OR' | 'NOT'; + children?: FilterCondition[]; + + relation?: string; + relationField?: string; + + customHandler?: string; + customHandlerName?: string; + customHandlerParams?: any; + + rawSQL?: string; // legacy shape, not executed by QueryCriteria + rawSQLParams?: any[]; +} +``` + +Usage map (current code): + +| Field | Used in code | Notes | +|---|---|---| +| `id` | yes | Condition identity in UI/backend validation | +| `field` | yes | Standard field filtering path | +| `operator` | yes | Required for query mapping and validation | +| `value` | yes | Operator payload | +| `logic` | yes | Group logic (`AND`/`OR`/`NOT`) in backend/query builder | +| `children` | yes | Nested group conditions in backend/query builder | +| `relation` | yes | Relation filter path, converted to adapter-neutral criteria | +| `relationField` | yes | Related field in relation filter | +| `customHandler` | yes | Custom handler ID for custom filter execution | +| `customHandlerName` | yes | UI display label (optional, fallbacks exist) | +| `customHandlerParams` | yes | Optional params passed to custom handler execution | +| `rawSQL` | legacy | Not executed by `QueryCriteria`; use a custom handler that returns `criteria` | +| `rawSQLParams` | legacy | Kept for compatibility with older condition payloads | + +`FilterCondition` is a UI/saved-filter format. Runtime execution converts it into `QueryCriteria`; ORM adapters do not receive `FilterCondition` directly. + +## Operators + +`FilterOperator` currently includes: + +- `eq`, `neq` +- `gt`, `gte`, `lt`, `lte` +- `like`, `ilike`, `startsWith`, `endsWith` +- `in`, `notIn` +- `between` +- `today`, `month`, `year`, `monthBetween`, `yearBetween` +- `isNull`, `isNotNull` +- `regex` +- `custom` + +Type-specific availability is enforced in `ConditionValidator` and `QueryBuilder`. + +## Query Execution + +Filters are not passed to ORM adapters directly. The execution path is: + +```text +FilterCondition[] + list/search params + -> QueryBuilder + -> QueryCriteria + -> ORM adapter +``` + +`QueryCriteria` is documented in [Internal Queries](InternalQueries.md). It contains only adapter-neutral fields such as `where`, `select`, `populate`, `sort`, `limit`, and `skip`. UI concepts such as `filters`, `globalSearch`, and visible table columns stay outside the internal query language. + +### Important UI note + +The current filter panel supports a flat condition list (combined with `AND`). +Nested `AND/OR/NOT` groups are supported by backend condition format and query builder, but the standard UI does not provide a group editor. + +### Relation filter note + +- Relation conditions are converted to adapter-neutral relation criteria. +- In the current UI and validator flow for relation conditions, allowed operators are `eq` and `neq`. + +## Visibility and Permissions + +Visibility types: + +- `private` - owner + admin +- `groups` - users whose groups intersect with `groupIds` + admin +- `public` - all users with access to the model +- `system` - visible to all users with model access; excluded from saved filter list by default + +Edit/delete rules: + +- admins can edit/delete any filter; +- non-admin users can edit/delete only their own filters. + +Creation rules in `POST /filter`: + +- `public` is allowed only for admins; +- `groups` is allowed for admins or users with `manage-group-filter-visibility` permission token; +- otherwise visibility is forced to `private`. + +When editing someone else's filter, visibility is preserved. + +## Backend API + +Routes are registered under `/model/:entityName/...` patterns. + +### Filter metadata and fields + +- `GET /adminizer/model/:model/filter-fields` + - Returns available fields for filter UI. + - Applies model filter config: `models..filters` (or legacy `modelFilters.`). + - Applies `excludeFromFilters`. + - Always excludes `id`, `ownerId`, `updatedAt`. + - Relation fields are returned only for Sequelize. + - Custom filter metadata is returned for Sequelize fields with `customFilter.handlerId`. + +### Saved filters + +- `GET /adminizer/model/:model/saved-filters` + - Returns accessible filters with: + - owner info (`ownerInfo`) + - computed result count (`resultCount`) + +- `POST /adminizer/model/:model/filter` + - Creates or updates a saved filter. + - Update mode uses `filterId` in body. + - Optional: `columns`, `visibility`, `groupIds`, `sortField`, `sortDirection`, `icon`, `color`. + - For private owner filters, supports `apiEnabled` and `regenerateApiKey`. + - If same-name filter already exists for the same owner and `filterId` is not provided, returns `409` with `requiresConfirmation: true`. + +- `DELETE /adminizer/model/:model/filter/:id` + - Returns `204` for successful deletion. + - Also returns `204` for "not found or access denied" to avoid ownership enumeration. + +### Temporary filters + +- `POST /adminizer/model/:model/filter/apply` + - Stores temporary filter in server session as `req.session.temporaryFilters[modelName]`. + - Returns `filterId: "temporary"`. + +- `GET /adminizer/model/:model/filter/temporary` + - Reads temporary filter from server session. + +Frontend additionally stores temporary filter meta in `sessionStorage` for local UI state; backend source of truth is server session. + +### Columns + +- `GET /adminizer/model/:model/columns?filterId=` + - Returns `availableColumns` and current `filterColumns`. + +- `POST /adminizer/model/:model/filter/:filterId/columns` + - Replaces column rows for the filter. + +### Groups + +- `GET /adminizer/groups` + - Returns group list for group visibility selector. + - Protected by `manage-group-filter-visibility` permission token. + +### Cross-model user filter list + +- `GET /adminizer/api/all-user-filters` + - Returns all filters visible to the current user across models. + - Supports query params: `search`, `modelName`, `offset`, `limit`. + +## Public Feed API + +Feed routes: + +- `GET /adminizer/api/feed/:apiKey` +- `GET /adminizer/api/feed/:apiKey.:format` (`json` or `xml`) + +Requirements: + +- filter must be `visibility: 'private'`; +- filter must have `apiEnabled: true`; +- caller must provide `userKey` query parameter; +- auth must be enabled globally (`config.auth.enable`); +- `userKey` must match an existing user API key (`User.apiKey`). + +Helper endpoints for user API keys: + +- `GET /adminizer/api/user-key` +- `POST /adminizer/api/user-key/regenerate` + +## Custom Filter Handlers + +Custom handler runtime: + +- registry class: `CustomFilterHandler` +- handler base class: `AbstractCustomFilter` +- handler id format: `ModelName.fieldName` + +Register handlers programmatically: + +```ts +adminizer.customFilterHandler.add(new MyFilterHandler(), { force: true }); +``` + +Attach handler to model field: + +```ts +fields: { + myJson: { + type: 'jsoneditor', + customFilter: { + handlerId: 'Example.myJson' + } + } +} +``` + +Notes: + +- custom filter metadata is exposed by `/filter-fields` only for Sequelize fields; +- handler `inputConfig` supports up to 3 inputs; +- in UI, custom condition is serialized with `operator: 'custom'` and `customHandler`. + +## Validation and Security Limits + +Execution-level limits (`FILTER_SECURITY_LIMITS` in `QueryBuilder`): + +| Limit | Value | +|---|---| +| Max nesting depth | 10 | +| Max conditions per group | 100 | +| Max total conditions | 500 | +| Max `IN` values | 1000 | +| Max string length | 10000 | + +`ConditionValidator` provides extra condition validation helpers (field/type/operator/value checks and legacy raw SQL pattern checks), but it is a utility class and is not the only enforcement layer. Query execution still validates key constraints in `QueryBuilder`. + +## Model Configuration + +Global switch: + +```ts +filters: { + enabled: true +} +``` + +Per-model filter configuration: + +```ts +models: { + Example: { + model: 'Example', + title: 'Example', + filters: { + enabled: true, + excludeFromFilters: ['createdAt', 'updatedAt'] + } + } +} +``` + +Legacy fallback is still supported: + +```ts +modelFilters: { + Example: { + excludeFromFilters: ['createdAt'] + } +} +``` diff --git a/docs/Forms.md b/docs/Forms.md index 58060afe..1954af72 100644 --- a/docs/Forms.md +++ b/docs/Forms.md @@ -1,60 +1,5 @@ # Forms -You can add forms when you have no need to create a Model. For example, for -single block on page that will not be repeated. Or like a link with model settings. +Legacy configuration-driven Forms are no longer used starting with Adminizer 5. -You can add forms directly to adminpanel configuration or put them in files with -`.json` extension which should be named as form slug. Put your forms in your -directory and write down path to it in `path` field. - -```javascript -module.exports.adminpanel = { - forms: { - path: 'forms', - data: { - formSlug: { - field1: { - title: "Field1", - type: "string", - value: "Some string", - required: true, - tooltip: 'tooltip for field1', - description: "some description" - } - } - } - }, -} -``` - -- path - relative path to your forms from project directory -- data - object with forms which are named by unique slug - -You can use forms by adding adminpanel special links like `tools`, `additionaLinks` -in navbar, `global` or `inline` actions. To do that read [Links](Links.md) - -## How to create a form - -Forms is `.json` files that contains fields with `BaseFieldConfig` options. -`title` and `type` are required. - -```json -{ - "label": { - "title": "Label", - "type": "string", - "value": "From example from file", - "required": true, - "tooltip": "tooltip for label", - "description": "some description" - }, - "teaser": { - "title": "Тизер", - "type": "text", - "required": true, - "tooltip": "tooltip for teaser", - "description": "some description", - "value": "" - } -} -``` +Use model create/edit pages and model configuration instead. diff --git a/docs/GlobalUI.md b/docs/GlobalUI.md index 21b0b84f..2f120f4c 100644 --- a/docs/GlobalUI.md +++ b/docs/GlobalUI.md @@ -37,3 +37,49 @@ export default function Example() { Make sure the Adminizer bundle is loaded before your module so that `registerUIComponents()` has populated `window.UIComponents`. The `registerUIComponents()` function automatically creates `window.UIComponents` if it doesn't already exist. This avoids runtime errors like `Uncaught TypeError: Cannot convert undefined or null to object` when the container is missing. + +## Global search + +The panel header carries a search palette, opened with `Ctrl/Cmd+K` or its button. It searches the +**navigation registry** — the same server-side list of pages the sidebar and the AI assistant read +(`GET {routePrefix}/api/links/search?q=`), so results are filtered by the user's permissions: a page +someone may not open never appears. + +An app page reaches the palette by being registered, not by anything frontend-side: + +| Registration | Appears as | +|---|---| +| `ctx.adminLink({id, title, link, …})` | a page, openable from the palette | +| `navbar.additionalLinks` / model config | a page, openable from the palette | +| `ctx.adminLinkTemplate({template: "/x/:id"})` | a search hit, but not yet openable — a parametrized page needs an id | + +The palette is also the handler of the assistant's `search-admin-links` browser action +(`adminizer:ai-search-admin-links`): it opens with the agent's query prefilled. + +## Built-In Control Globals + +`window.JSComponents` also exposes selected built-in controls such as `VanillaJSONEditor`, `HandsonTable`, and `MonacoEditor`. + +These exports are lazy proxies. Registering UI globals does not place the control implementations in the main Adminizer bundle and does not download them on dashboard entry. The corresponding ES module and production stylesheet are loaded when the proxy is first rendered. + +When app modules need the heavy built-in controls in local development, import Adminizer's lazy proxy modules instead of importing `@/components/...` directly: + +```tsx +import HandsonTable from "@/js-components/handsontable"; +import MonacoEditor from "@/js-components/monaco"; +import VanillaJSONEditor from "@/js-components/jsoneditor"; +``` + +Externalize the same imports in the module Vite config: + +```ts +viteExternalsPlugin({ + "@/js-components/handsontable": ["JSComponents", "HandsonTable"], + "@/js-components/monaco": ["JSComponents", "MonacoEditor"], + "@/js-components/jsoneditor": ["JSComponents", "VanillaJSONEditor"], +}); +``` + +This keeps development and production behavior aligned: the module loads only a small proxy first, while the control implementation and styles are loaded on first render. + +App-owned custom controls should use `ctx.control()` instead of adding new entries to `window.JSComponents`. See [Controls](Controls.md). diff --git a/docs/HistoryActions.md b/docs/HistoryActions.md index e64e8bf0..1f7ea74a 100644 --- a/docs/HistoryActions.md +++ b/docs/HistoryActions.md @@ -30,7 +30,7 @@ An abstract base class defining the contract for all history adapters. It includ ## Default Adapter: `DefaultHistoryAdapter` -This built-in adapter uses the `HistoryActionsAP` model to persist history records in the database. +This built-in adapter uses the canonical `HistoryActions` system model to persist history records in the database. ### How It Works @@ -59,7 +59,7 @@ This built-in adapter uses the `HistoryActionsAP` model to persist history recor 4. **Security** - Respects RBAC (Role-Based Access Control). - - Internal models (like `UserAP`, `MediaManagerAP`, etc.) are excluded by default. + - Adminizer infrastructure models are excluded by default. The app-owned media manager models remain excluded by name for v5 compatibility, but they are not core system models. - Configurable extra exclusions via `config.history.excludeModels`. --- @@ -94,7 +94,7 @@ You can replace or extend the default behavior by implementing your own adapter. ```ts import { AbstractHistoryAdapter } from '../lib/history-actions/AbstractHistoryAdapter'; -import { HistoryActionsAP, UserAP } from '../models'; +import { HistoryActions, User } from '../models'; export class MyCustomHistoryAdapter extends AbstractHistoryAdapter { public id = 'myadapter'; // must be unique @@ -153,4 +153,4 @@ These are auto-registered with the Adminizer access rights system. ## Notes The system is opt-out: all models are tracked unless listed in excludeModels. -Future improvements may include diff-only storage and compression. \ No newline at end of file +Future improvements may include diff-only storage and compression. diff --git a/docs/InertiaAdapter.md b/docs/InertiaAdapter.md index 83c13166..a5c6e72a 100644 --- a/docs/InertiaAdapter.md +++ b/docs/InertiaAdapter.md @@ -144,16 +144,16 @@ adminizer.app.use( ); req.Inertia.setViewData({ - lang: req.session.UserAP?.locale || 'en', + lang: req.session.User?.locale || 'en', }) const menuHelper = new InertiaMenuHelper(adminizer) req.Inertia.shareProps({ auth: { - user: req.session.UserAP + user: req.session.User }, - menu: req.session.UserAP ? menuHelper.getMenuItems(req) : null, + menu: req.session.User ? menuHelper.getMenuItems(req) : null, brand: menuHelper.getBrandTitle(), logout: menuHelper.getLogoutUrl(), //... @@ -161,8 +161,9 @@ req.Inertia.shareProps({ ``` `menuHelper.getMenuItems(req)` returns sidebar items already localized on the server (titles, sections, and nested action titles are translated via `req.i18n.__(...)` before sending Inertia props). -You can also share a common dictionary (for example `uiMessages`) via `shareProps` and consume it in React components for reusable interface labels. -For dashboard widgets, shared `uiMessages` can include keys like `Show`, `Hide`, `On`, `Off`, and `No widgets found`. +You can also share a common dictionary via `i18n.common` in `shareProps` and consume it in React components for reusable interface labels. +For dashboard widgets, common keys can include labels like `Show`, `Hide`, `On`, `Off`, and `No widgets found`. +Page-specific dictionaries should be passed via `i18nPage`. **See the implementation here** `src/system/bindInertia.ts` ### Usage in controllers diff --git a/docs/Install.md b/docs/Install.md index 432d1b4c..0bd898f2 100644 --- a/docs/Install.md +++ b/docs/Install.md @@ -1,170 +1,191 @@ -# How to Start Adminizer - -To launch **Adminizer**, you need to follow a few simple steps: - ---- - -## 1. Create an Adminizer instance - -You must create an instance of the `Adminizer` class. -When creating it, you should pass an array of adapters — adapters are responsible for connecting Adminizer to your data sources (e.g., SequelizeAdapter or WaterlineAdapter). - ---- - -## 2. Configure the adapters - -Each adapter acts as a bridge between Adminizer and your models. -You need to prepare and configure at least one adapter: -- For **Sequelize**, create a database connection and register your models. -- For **Waterline**, create the ORM, register models, and initialize it with configuration. -- Or _use any other ORM_ by implementing a custom adapter that extends AbstractAdapter. -Adminizer is designed to be flexible and can be integrated with any ORM or data source. ---- - -## 3. Initialize Adminizer - -After creating the Adminizer instance, call the `init` method and pass the project configuration, which includes: -- the route prefix (e.g., `/adminizer`), -- the project name, -- whether authentication is required, -- and optionally database connection settings. - ---- - -## 4. Create an HTTP server - -To access Adminizer through the browser, you must create an HTTP server (e.g., using Node.js `http` module). -You should forward all requests starting with the route prefix (like `/adminizer`) to Adminizer’s application handler. -Other requests can be processed separately (e.g., by returning a basic welcome page). - ---- - -## 5. Start the server - -Once everything is set up, start the server and listen on a port (typically 3000). -After that, the Adminizer panel will be available at a URL like `http://localhost:3000/adminizer`. - ---- - -# What Happens After Startup - -- When a user navigates to `/adminizer`, the request is routed to Adminizer. -- Adminizer renders the administrative panel UI. -- If models were registered via adapters, Adminizer automatically displays them for: - - viewing records, - - creating new entries, - - editing and deleting records. -- If authentication is enabled, users must log in before accessing the panel. - ---- - -# Minimum Requirements to Run Adminizer - -- An Adminizer instance (`new Adminizer([...])`). -- At least one adapter (can be empty or connected to a database). -- A call to the `init` method with basic configuration. -- A server that routes requests to Adminizer. - ---- - -# Troubleshooting Adminizer Startup - -If you encounter difficulties launching **Adminizer**, -you can use the ready-made **fixture** (template project) available in the repository folder [`fixture`](https://github.com/adminization/adminizer/tree/main/fixture). - -This project already includes: -- a fully configured Adminizer setup, -- working Waterline models, -- example seed data, -- and a minimal configuration required to start. - -It serves as a practical starting point to help you quickly understand how Adminizer, models, adapters, and configuration fit together. - -### How to use the armature: - -```bash -git clone https://github.com/adminization/adminizer.git -cd adminizer -npm install --force -npm run demo:build && npm run demo -``` - -After running these commands, you will have a fully working Adminizer instance ready for testing and development at `http://localhost:3000/adminizer`. - ---- - -Example: - -```typescript -import { Adminizer } from "@adminization/adminizer"; -import { SequelizeAdapter } from "@adminization/adminizer/v4/model/adapter/sequelize"; -import { Sequelize, DataTypes } from "sequelize"; -import http from "http"; - -async function start() { - // 1. Initialize Sequelize - const sequelize = new Sequelize('sqlite::memory:', { logging: false }); - - // 2. Define a model - const Example = sequelize.define('example', { - id: { - type: DataTypes.INTEGER, - autoIncrement: true, - primaryKey: true, - }, - title: { - type: DataTypes.STRING, - allowNull: false, - }, - description: { - type: DataTypes.TEXT, - }, - }, { - tableName: 'examples', - timestamps: false, - }); - - // 3. Synchronize the database (this will create the table) - await sequelize.sync({ force: true }); - - // 4. Create an adapter - const sequelizeAdapter = new SequelizeAdapter({ - sequelize: sequelize, - models: { example: Example }, - }); - - // 5. Create Adminizer instance - const adminizer = new Adminizer([sequelizeAdapter]); - - await adminizer.init({ - routePrefix: "/adminizer", - projectName: "Adminizer with Sequelize", - dbConnection: null, - auth: false, - buildAssets: true, - }); - - // 6. HTTP server - const mainApp = http.createServer((req, res) => { - if (req.url.startsWith("/adminizer")) { - adminizer.app(req, res, (err) => { - if (err) { - res.writeHead(500, { 'Content-Type': 'text/plain' }); - res.end('Internal Server Error'); - } - }); - } else { - res.writeHead(200, { 'Content-Type': 'text/html' }); - res.end('

Welcome

Go to Adminizer

'); - } - }); - - mainApp.listen(3000, () => { - console.log('Server started: http://localhost:3000/adminizer'); - }); -} - -start(); - -``` - +# How to Start Adminizer + +To launch **Adminizer**, you need to follow a few simple steps: + +--- + +## 1. Create an Adminizer instance + +You must create an instance of the `Adminizer` class. +When creating it, you should pass an array of adapters — adapters are responsible for connecting Adminizer to your data sources. Sequelize is the primary supported adapter. TypeORM support exists, but is experimental. + +--- + +## 2. Configure the adapters + +Each adapter acts as a bridge between Adminizer and your models. +You need to prepare and configure at least one adapter: +- For **Sequelize**, create a database connection and register your models. +- For **TypeORM**, use the experimental `TypeOrmAdapter` only after testing it against your project models. +- Or _use any other ORM_ by implementing a custom adapter that extends AbstractAdapter. +Adminizer is designed to be flexible and can be integrated with any ORM or data source. + +The host application must register the required Adminizer system models before calling `adminizer.init()`. Adminizer validates those models at startup but does not create or synchronize them. See [System Models](Configuration/Models.md). + +Adminizer core refers to system models by canonical names such as `User` and `Group`. If your ORM models use different names, pass the mapping through the adapter `systemModels` option. + +--- + +## 3. Initialize Adminizer + +After creating the Adminizer instance, call the `init` method and pass the project configuration, which includes: +- the route prefix (e.g., `/adminizer`), +- the project name, +- whether authentication is required, +- and optionally database connection settings. + +--- + +## 4. Create an HTTP server + +To access Adminizer through the browser, you must create an HTTP server (e.g., using Node.js `http` module). +You should forward all requests starting with the route prefix (like `/adminizer`) to Adminizer’s application handler. +Other requests can be processed separately (e.g., by returning a basic welcome page). + +--- + +## 5. Start the server + +Once everything is set up, start the server and listen on a port (typically 3000). +After that, the Adminizer panel will be available at a URL like `http://localhost:3000/adminizer`. + +--- + +# What Happens After Startup + +- When a user navigates to `/adminizer`, the request is routed to Adminizer. +- Adminizer renders the administrative panel UI. +- If models were registered via adapters, Adminizer automatically displays them for: + - viewing records, + - creating new entries, + - editing and deleting records. +- If authentication is enabled, users must log in before accessing the panel. + +--- + +# Minimum Requirements to Run Adminizer + +- An Adminizer instance (`new Adminizer([...])`). +- At least one adapter containing the required Adminizer system models. +- A call to the `init` method with basic configuration. +- A server that routes requests to Adminizer. + +--- + +# Troubleshooting Adminizer Startup + +If you encounter difficulties launching **Adminizer**, +you can use the ready-made **fixture** (template project) available in the repository folder [`fixture`](https://github.com/adminization/adminizer/tree/main/fixture). + +This project already includes: +- a fully configured Adminizer setup, +- working Sequelize models, +- experimental TypeORM models, +- example seed data, +- and a minimal configuration required to start. + +It serves as a practical starting point to help you quickly understand how Adminizer, models, adapters, and configuration fit together. + +### How to use the armature: + +```bash +git clone https://github.com/adminization/adminizer.git +cd adminizer +npm install --force +npm run build +npm run start:seed +``` + +After running these commands, you will have a fully working Adminizer instance ready for testing and development at `http://localhost:3000/adminizer`. + +--- + +Example: + +```typescript +import { Adminizer, SequelizeAdapter } from "adminizer"; +import { Sequelize, DataTypes } from "sequelize"; +import express from "express"; +import { registerSequelizeSystemModels } from "./models/adminizer-system-models"; + +async function start() { + // 1. Initialize Sequelize + const sequelize = new Sequelize('sqlite::memory:', { logging: false }); + registerSequelizeSystemModels(sequelize); + + // 2. Define a model + const Example = sequelize.define('example', { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + title: { + type: DataTypes.STRING, + allowNull: false, + }, + description: { + type: DataTypes.TEXT, + }, + }, { + tableName: 'examples', + timestamps: false, + }); + + // 3. Synchronize the database (this will create the table) + await sequelize.sync({ force: true }); + + // 4. Create an adapter and map Adminizer system names to host ORM model names + const sequelizeAdapter = new SequelizeAdapter(sequelize, { + systemModels: { + User: "UserAP", + Group: "GroupAP", + Filter: "FilterAP", + FilterColumn: "FilterColumnAP", + HistoryActions: "HistoryActionsAP", + Notification: "NotificationAP", + UserNotification: "UserNotificationAP", + }, + }); + + // 5. Create Adminizer instance + const adminizer = new Adminizer([sequelizeAdapter]); + + await adminizer.init({ + routePrefix: "/adminizer", + auth: { enable: false }, + models: { + Example: { + title: "Examples", + model: "example", + fields: { + id: { title: "ID", type: "integer" }, + title: { title: "Title", type: "string" }, + description: { title: "Description", type: "text" }, + }, + list: true, + add: true, + edit: true, + view: true, + remove: true, + }, + }, + }); + + // 6. HTTP server + const mainApp = express(); + mainApp.use(adminizer.getMiddleware()); + mainApp.get("/", (_req, res) => { + res.type("html").send('

Welcome

Go to Adminizer

'); + }); + + mainApp.listen(3000, () => { + console.log('Server started: http://localhost:3000/adminizer'); + }); +} + +start(); + +``` + +The media manager is not a built-in system model set. Its storage models and implementation must be provided by an app and enabled after `adminizer.init()`. See [Media Manager](MediaManager.md) and the reference implementation in `fixture/apps/media-manager`. diff --git a/docs/InternalQueries.md b/docs/InternalQueries.md new file mode 100644 index 00000000..e569d0bd --- /dev/null +++ b/docs/InternalQueries.md @@ -0,0 +1,221 @@ +# Internal Queries + +Adminizer uses an internal query language named `QueryCriteria` between higher-level services and ORM adapters. The goal is to keep Adminizer code independent from a specific ORM query syntax. UI filters, global search, and custom filter state are converted before execution; they are not part of `QueryCriteria`. + +## QueryCriteria + +```ts +interface QueryCriteria { + where?: CriteriaWhere; + select?: string[] | Record; + populate?: Record; + sort?: string | Record; + limit?: number; + skip?: number; +} +``` + +Basic example: + +```ts +await model.find({ + where: { + parent: null, + mimeType: { contains: "image" }, + group: "default" + }, + sort: "createdAt DESC", + limit: 20, + skip: 0, + select: ["id", "filename", "url"] +}); +``` + +## Operators + +Field values can be primitives, arrays, or operator objects. + +```ts +await model.find({ + where: { + status: "published", + title: { contains: "release" }, + createdAt: { between: [fromDate, toDate] }, + id: { in: [1, 2, 3] }, + deletedAt: { isNull: true } + } +}); +``` + +Supported operators: + +| Operator | Meaning | +|---|---| +| `eq` | Equals | +| `ne` | Not equals | +| `gt`, `gte`, `lt`, `lte` | Numeric/date comparisons | +| `contains` | String contains | +| `startsWith`, `endsWith` | String prefix/suffix | +| `in`, `notIn` | Value is in/not in array | +| `between` | Inclusive range | +| `isNull`, `isNotNull` | Null checks | +| `regex` | Adapter-supported regular expression | +| `jsonContains` | Adapter-supported JSON containment | + +Arrays are treated as `in`: + +```ts +await model.find({ where: { id: [1, 2, 3] } }); +``` + +## Boolean Groups + +`and`, `or`, and `not` are adapter-neutral boolean groups. + +```ts +await model.find({ + where: { + and: [ + { status: "published" }, + { + or: [ + { title: { contains: "Adminizer" } }, + { description: { contains: "Adminizer" } } + ] + }, + { not: { archived: true } } + ] + } +}); +``` + +## Populate + +`populate` describes relations to load. A relation can be `true` or another `QueryCriteria`. +When a relation criteria contains `select`, the selected fields are evaluated against the related model, not the parent model. + +```ts +await mediaModel.find({ + where: { parent: null }, + populate: { + variants: { + sort: "createdAt DESC", + select: ["id", "url", "mimeType"] + }, + meta: { + where: { isPublic: true } + } + } +}); +``` + +Nested `populate` is allowed: + +```ts +await notificationModel.find({ + populate: { + userNotifications: { + populate: { + userId: true + } + } + } +}); +``` + +## QueryBuilder Boundary + +`QueryBuilder` accepts UI-oriented parameters such as `filters`, `globalSearch`, and `fields`, then converts them into `QueryCriteria`. + +This separation is intentional: + +- `FilterCondition[]` belongs to the filter UI and saved filter models. +- `globalSearch` belongs to list/table UX. +- `fields` is a display/export selection. +- ORM adapters receive only `QueryCriteria`. + +Raw SQL and in-memory custom filter results are not part of `QueryCriteria`. Custom filters that participate in database queries should return adapter-neutral `criteria`. + +## Internal Model Access + +System modules should access internal models through `ModelHandler`: + +```ts +const user = await adminizer.modelHandler + .internal("widgets") + .get("User") + .findOne({ where: { login: "admin" } }); +``` + +When the project maps a physical ORM model to a different Adminizer resource name, pass the Adminizer resource name to `internal(...).get()` and `getResource()`, not the host model name: + +```ts +// Customer is an Adminizer resource mapped to the host ORM model User. +const customers = await adminizer.modelHandler + .internal("my-module") + .get("Customer") + .find({}); + +// Preferred when no scoped internal repository is required. +const customerModel = adminizer.modelHandler.getResource("Customer"); +``` + +`modelHandler.model.get("UserAP")` is a deprecated compatibility lookup for older system integrations. New code should use canonical resource names with `getResource()` or `internal(...).get()`. + +This path is intended for trusted Adminizer subsystems that must bypass `DataAccessor` user filtering. It replaces direct calls to protected adapter methods such as: + +```ts +// Do not use in application/module code. +adminizer.modelHandler.model.get("User")["_findOne"](...) +``` + +## Allowlist Scopes + +Each internal access scope has an allowlist of models. Built-in scopes include: + +| Scope | Purpose | +|---|---| +| `auth` | Login, registration, init user | +| `users` | Built-in user/group controllers | +| `filters` | Saved filters and filter columns | +| `media-manager` | Legacy v5 access to app-owned media manager models | +| `history` | History actions | +| `notifications` | Notification models | +| `navigation` | Navigation storage | +| `widgets` | Dashboard widgets | +| `feed` | Feed export | +| `data-accessor` | `DataAccessor` lookups for object-form `userAccessRelation` | + +The `data-accessor` scope is filled automatically from the model configs: every intermediate model +named by an object-form `userAccessRelation` — including the `through` membership model and `Group` +when the `{field, through, via, group}` form is used — is whitelisted +(see [User-Owned Records](AccessRights/user-owned-records.md)). The `accessGraph` section adds its +own lookups the same way: each graph's membership model, `Group` when the membership declares +`group`, and every graph model whose id list feeds the next level down. The allowlist is built at +boot and rebuilt whenever an app config layer changes the merged config or an app registers or +unregisters a model, so runtime-enabled apps get their lookups without a restart. + +Projects can extend the allowlist with `system.internalModelAccess`: + +```ts +const config = { + system: { + internalModelAccess: { + "my-module": ["User", "Filter"] + } + } +}; +``` + +Then use the custom scope: + +```ts +const filters = await adminizer.modelHandler + .internal("my-module") + .get("Filter") + .find({ where: { modelName: "Example" } }); +``` + +Use `DataAccessor` for normal user-facing model operations. Use `internal(...)` only for system-level Adminizer modules or trusted integrations. + +The `media-manager` scope is retained for compatibility. It does not make media manager models part of the core system contract and does not create them. New media manager apps should declare model access through `ctx.modelAccess()` and use `runtime.models`. diff --git a/docs/KnowledgeBase.md b/docs/KnowledgeBase.md new file mode 100644 index 00000000..08dd7703 --- /dev/null +++ b/docs/KnowledgeBase.md @@ -0,0 +1,349 @@ +# Knowledge Base (built-in documentation) + +The **knowledge base** turns Adminizer into its own manual: articles are shown in a viewer at +`{routePrefix}/docs`, offered contextually behind an **i** button on every page, and readable by the +AI assistant — always with the reader's own access rights applied. + +Where the documents live is **not** part of the feature. The only contract is the abstract class +`AbstractDocumentation`; files, a database table, an external service or generated content are all +equally valid implementations, and none of them can leak into the UI, the API or the assistant. + +The feature is **opt-in twice**: it must be enabled in the config *and* an implementation must be +registered. Either one missing brings up nothing — no routes, no tokens, no navigation entry, no +assistant skills. + +--- + +## Enabling the feature + +### 1. Switch it on + +```ts +const config: AdminpanelConfig = { + documentation: { + enabled: true, + }, +}; +``` + +That is the whole config surface. Where documentation comes from is decided in code, not in +configuration. + +### 2. Register an implementation + +```ts +import {Adminizer, FileDocumentation} from 'adminizer'; + +await adminizer.init(config); + +adminizer.documentationHandler.register(new FileDocumentation({ + dir: path.resolve(import.meta.dirname, 'documentation'), + watch: true, +})); +``` + +One implementation is active per Adminizer instance. Registering a second one throws; replacing +means `unregister()` then `register()`. An app can own the implementation by passing its name as the +second argument (`register(service, appName)`), and `unregister(appName)` removes only its own. + +--- + +## How it works + +### What is brought up, and when + +The wiring lives in one place — `bindDocs` — and runs the moment the feature has **both** of its +switches: `documentation.enabled: true` in the config and an implementation registered. In practice +that is inside `register()`, since the implementation arrives after `init()`; registering it at any +later point is fine too — the routes are mounted at registration, the same way the feedback +controller is. The wiring brings up: + +* the base token `read-documentation` (department `documentation`); +* the viewer in the navigation, and `{routePrefix}/docs/:id` as a link template, so the assistant + discovers both through `list_admin_navigation` and can link straight to an article; +* the three documentation skills of the assistant; +* the HTTP API `{routePrefix}/docs/api/*` and the two viewer pages. + +While either switch is missing, none of that exists: no token to hand out, no navigation entry, no +skills, and a request to `{routePrefix}/docs/...` gets the same `404` as any unknown path. The one +asymmetry is a runtime `unregister()`: mounted routes cannot be unmounted, so from then on the API +answers `404` and the viewer shows an empty knowledge base until another implementation is +registered. + +### A request, end to end + +```text +GET {routePrefix}/docs/api/search?q=widgets + ↓ requireAuthAPI() 401 for an anonymous caller + ↓ requirePermission(read-documentation) 403 without the base token + ↓ controller query string → DocSearchQuery, resolves the locale + ↓ DocumentationHandler calls the implementation, then drops every document + ↓ AbstractDocumentation this user may not read + → JSON ids, titles and bindings — nothing else +``` + +Three properties fall out of that order: + +* **The implementation never sees a user.** It cannot forget a permission check, because it is not + the one doing them — `DocumentationHandler` filters everything it returns. +* **Rights are re-applied per call.** The viewer, the "i" drawer, the HTTP API and the assistant all + go through the same facade, so there is no path where a document leaks past the check — including + link resolution, which is why an unreadable target renders as plain text. +* **Storage never leaks.** What crosses the wire is `DocMeta` and markdown; even + `accessRightsToken` stays on the server. Nothing in the browser knows whether the article came + from a file, a table or an HTTP call. + +The viewer page is rendered with the id of the article and nothing else — the content is fetched by +the page through the same API, so a deep link, a reload and a click in the tree all follow the same +path. + +### The contextual table of contents + +Every Inertia response carries a `docs` shared prop: the documents bound to the current path, as +metadata only (`{count, items: [{id, title, section}]}`). It is built in `bindInertia` from +`forContextMeta(user, req.path)`, and is `null` when the subsystem is off or the user may not read +documentation. + +That is what makes the "i" button work without any page knowing about documentation: the layout +renders it when the prop has entries — or when the page is a model page, which always has a schema +to show — and the drawer loads the article itself on demand. A failure inside +the implementation is logged and turns into `null` — a broken knowledge base cannot break the +pages of the panel. + +### Locale + +Consumers ask for a locale, they do not negotiate one. A request resolves it in this order: + +1. the explicit `locale` query parameter (the locale chips in the viewer use it); +2. the reader's own locale; +3. `translation.defaultLocale` from the config. + +The implementation then serves the best version it has and reports which locale that actually was, +so the viewer can show what the reader is looking at. + +### Caching and freshness + +The facade caches nothing: every call reaches the implementation, which is where caching belongs. +`FileDocumentation` caches its directory scan and, with `watch: true`, drops the cache when a file +changes. An implementation that can notice changes exposes `onChange`, which is how it tells +consumers to drop theirs. + +--- + +## The contract + +```ts +abstract class AbstractDocumentation { + // Required + abstract list(locale?: string): Promise; + abstract get(id: string, locale?: string): Promise; + + // Implemented on top of the two above; override when you can do better + search(query: DocSearchQuery, locale?: string): Promise; + forContext(ctx: {url?: string; model?: string}, locale?: string): Promise; + keywords(locale?: string): Promise>; + resolveLink(fromId: string, ref: string): Promise; + + // Optional: tell consumers to drop their caches + onChange?(cb: (ids?: string[]) => void): void; +} +``` + +`DocMeta` is everything the outside world learns about a document: + +| Field | Type | Meaning | +|----------------------|------------|----------------------------------------------------------------------| +| `id` | `string` | Stable identifier — the only handle that leaves the abstraction | +| `title` | `string` | Title in the requested locale when a translation exists | +| `section` | `string?` | Grouping in the viewer | +| `keywords` | `string[]` | Filter chips and the assistant's keyword map | +| `models` | `string[]` | Model resources the document is about (see access rights below) | +| `urls` | `string[]` | Express-style admin paths (`/admin/model/:name`) the document is bound to | +| `accessRightsToken` | `string?` | An **existing** token required on top of the base token | +| `locales` | `string[]?`| Locales this document is available in | + +Translations are requested, never negotiated: a consumer asks for a locale and `DocContent.locale` +reports which one was actually served. Search is a property of the implementation — grep, SQL +full-text, or a vector/RAG index — and none of the consumers change when it does. + +--- + +## Bundled implementation: `FileDocumentation` + +Markdown files with a small frontmatter header; a sibling file with a locale suffix is a translation +of the same document (`intro.md` + `intro.ru.md` → document `intro` in `en` and `ru`). + +```markdown +--- +title: Working with data models +section: Basics +keywords: [models, records, fields] +models: [Test, Example] +urls: [/adminizer/model/:modelResourceName] +accessRightsToken: read-secret-operations-doc +--- + +# Working with data models + +Text of the article. +``` + +| Option | Default | Meaning | +|-----------------|---------|------------------------------------------------------| +| `dir` | — | Directory scanned recursively for markdown files | +| `defaultLocale` | `en` | Locale of files without a suffix | +| `watch` | `false` | Watch the directory and drop the cache on changes | + +Structural metadata (`section`, `models`, `urls`, `accessRightsToken`) is taken from the +default-locale file; `title` and `keywords` are translated per file. The frontmatter parser is a +deliberately small `key: value` subset (quoted strings, `[a, b]` inline lists, `- item` lists), not +full YAML — and, like everything else here, a private detail of this implementation. + +Writing articles — file naming, the frontmatter keys, translations, bindings to pages and models, +links, diagrams and schema blocks — is described in +[Writing Knowledge Base Articles](KnowledgeBase/WritingArticles.md). + +--- + +## Access rights + +Implementations know nothing about users. Adminizer applies rights to every result before it reaches +the UI, the API or the assistant, so a document a reader may not open simply does not exist for +them: not in listings, not in search, not in the keyword map, and a link to it renders as plain text. + +* **Base token `read-documentation`** (department `documentation`) — registered automatically when + the feature is enabled. Without it the whole subsystem is invisible. +* **Per-document token** — the author writes `accessRightsToken: ` in the metadata, referring to + a token that already exists. The subsystem never creates tokens from documents; a reference to a + token nobody registered simply keeps the document administrator-only. +* **Model-bound documents** — a document with `models: [Test]` is visible to readers who have + `read-Test-model` for at least one of the listed models, the very token that gates the data. + +Registering a custom token is ordinary host or app code: + +```ts +adminizer.accessRightsHelper.registerToken({ + id: 'read-secret-operations-doc', + name: 'Secret operations doc', + description: 'Access to the "Secret operations" article', + department: 'documentation', +}); +``` + +Administrators pass every check, as everywhere else in the panel. + +--- + +## What users see + +* **Viewer** — `{routePrefix}/docs` (and `/docs/:id`): article tree by section, keyword chips, + full-text search with snippets, locale switcher, prev/next within a section, mini table of + contents. Search state lives in the query string (`/docs/models-guide?q=widgets&keyword=models`), + so any result is linkable. The viewer is also registered as a navigation entry and as an + assistant-visible link template. +* **Contextual "i" button** — every page carries its own documentation as a shared prop, so the + layout can offer it without the page knowing anything. `#info` opens the table of contents of the + current page, `#info=` opens one article + (`/adminizer/model/Order#info=orders-workflow` is a valid deep link). +* **Schema tab** — on a model page the drawer also shows the structure of the model as + `DataAccessor` reports it, so the reader sees exactly the fields their rights allow. +* **Send to assistant** — hands the *reference* to the assistant, not the text: the agent reads the + article with the `read_documentation` skill, and the server checks the rights again. In the + composer the article shows up as an attachment chip next to attached files — its label opens the + article back in the reader, its X takes it out of the context, and a message carrying nothing but + chips may still be sent. + +Two fenced blocks are rendered specially in articles: + + ```mermaid + flowchart LR + A[List] --> B[Edit form] + ``` + + ```schema + {"model": "Test"} + ``` + +`mermaid` renders a diagram (the library is loaded only when a diagram appears); `schema` renders the +field table of a model resource — fetched through the API, so it is filtered by the reader's rights, +or written out inline (`{"title": …, "fields": [{"name", "title", "type", "required"}]}`) when it +describes something that is not a model. A block that cannot be rendered falls back to plain code. + +--- + +## HTTP API + +All endpoints require authentication and the base token; per-document rights are applied on top. + +| Endpoint | Returns | +|---------------------------------------------------------|-------------------------------------------| +| `GET {routePrefix}/docs/api/list?locale=` | Metadata of readable documents | +| `GET {routePrefix}/docs/api/keywords?locale=` | `{keyword: [docId]}` | +| `GET {routePrefix}/docs/api/search?q=&keywords=&url=&model=&locale=` | Results with snippets | +| `GET {routePrefix}/docs/api/context?url=&model=` | Documents bound to a place in the panel | +| `GET {routePrefix}/docs/api/doc/:id?locale=` | Markdown + metadata (`404` when not readable) | +| `GET {routePrefix}/docs/api/resolve?from=&refs=` | In-document references → document ids | +| `GET {routePrefix}/docs/api/schema?model=` | Fields of a model resource for the reader | + +--- + +## Assistant skills + +When the feature is enabled, three skills are registered and carry the base token, so they can be +offered to non-administrator agents as well: + +| Skill | Purpose | +|------------------------------|------------------------------------------------------| +| `list_documentation_keywords`| Keyword map of the documents the user may read | +| `search_documentation` | `query` / `keywords` / `url` / `model` → snippets and ids | +| `read_documentation` | `id` → markdown in the user's locale | + +--- + +## Example: a database-backed implementation + +The contract covers editable, database-stored documentation without a single change in the core: + +```ts +import {AbstractDocumentation, DocContent, DocMeta} from 'adminizer'; + +export class DbDocumentation extends AbstractDocumentation { + constructor(private readonly model: MyDocModel) { + super(); + } + + async list(locale?: string): Promise { + const rows = await this.model.findAll({where: {published: true}}); + return rows.map((row) => ({ + id: row.slug, + title: row.translations[locale ?? 'en']?.title ?? row.title, + section: row.section, + keywords: row.keywords ?? [], + models: row.models ?? [], + urls: row.urls ?? [], + accessRightsToken: row.accessRightsToken ?? undefined, + locales: Object.keys(row.translations ?? {}), + })); + } + + async get(id: string, locale?: string): Promise { + const row = await this.model.findOne({where: {slug: id}}); + if (!row) return undefined; + const served = locale && row.translations[locale] ? locale : 'en'; + return { + meta: (await this.list(locale)).find((meta) => meta.id === id)!, + markdown: row.translations[served]?.body ?? row.body, + locale: served, + }; + } + + // Optional: let the database do the searching instead of the default scan + async search(query, locale) { /* … full-text query … */ } + + // Optional: drop consumer caches when an editor saves an article + onChange(cb: () => void) { this.model.afterSave(() => cb()); } +} +``` + +Register it exactly like the bundled one — the viewer, the "i" button, the API and the assistant keep +working unchanged. diff --git a/docs/KnowledgeBase/WritingArticles.md b/docs/KnowledgeBase/WritingArticles.md new file mode 100644 index 00000000..ddd06588 --- /dev/null +++ b/docs/KnowledgeBase/WritingArticles.md @@ -0,0 +1,267 @@ +# Writing Knowledge Base Articles + +This page is about the **mechanics**: how a file becomes an article, what the panel does with each +piece of metadata, and how to check the result. What to write about is your decision; everything +below is what the system actually does with what you wrote. + +It describes the bundled `FileDocumentation` — markdown files on disk. A custom implementation +invents its own source format, but the metadata it has to produce (`DocMeta`) and everything that +happens afterwards are the same; see [Knowledge Base](../KnowledgeBase.md) for the subsystem itself. + +--- + +## Where articles live + +The directory is named when the implementation is registered: + +```ts +adminizer.documentationHandler.register(new FileDocumentation({ + dir: path.resolve(import.meta.dirname, 'documentation'), + defaultLocale: 'en', // locale of files without a suffix + watch: true, // rescan when a file changes +})); +``` + +* The directory is scanned **recursively**; only `*.md` files are read. +* Subdirectories are for your own order only — **the id comes from the file name, not from the + path**. `basics/intro.md` and `advanced/intro.md` are the same document `intro`, and the one + scanned last wins. +* The scan result is cached in memory. With `watch: true` a change anywhere under `dir` drops the + cache (200 ms debounce) and the next request rereads the files; without it, restart the app. + +## Anatomy of a file + +```markdown +--- +title: Working with data models +section: Basics +keywords: [models, records, fields] +models: [Test, Example] +urls: [/adminizer/model/:modelResourceName] +accessRightsToken: read-secret-operations-doc +--- + +# Working with data models + +The body of the article, in markdown. +``` + +Everything between the two `---` lines is metadata; everything after is the article. A file without +a frontmatter block is a valid article too — it just carries no metadata, and its title is taken +from the first `#` heading. + +## The id + +The id is the file name without `.md` and without the locale suffix; a `id:` key in the frontmatter +overrides it. + +It is the **public handle of the article**: it appears in the viewer URL +(`/adminizer/docs/`), in the `#info=` deep link, in links from other articles, and in what +the assistant returns from its skills. Renaming a file changes the id and breaks every link and +bookmark pointing at it. + +* Keep it slug-like: lowercase, dashes, no spaces — it ends up in a URL. +* Avoid dots inside the name. A trailing `.xx` is read as a locale suffix, so `notes.io.md` becomes + document `notes` in locale `io`. + +## Frontmatter syntax + +The parser is a deliberately small subset — **not** YAML. Supported forms: + +| Form | Example | +|---|---| +| `key: value` | `section: Basics` | +| Quoted value | `title: "Orders: the full cycle"` | +| Inline list | `keywords: [models, records]` | +| Dash list | `models:` followed by indented `- Test` lines | + +```markdown +--- +title: Orders +models: + - Order + - OrderLine +--- +``` + +There is no nesting, no multi-line strings, no anchors. A line that is not a `key: value` pair and +not a dash item is ignored, and so is any key the panel does not know — which makes a stray line +silently do nothing rather than break the file. + +| Key | Type | What the panel does with it | +|---|---|---| +| `title` | string | Shown in the tree, the article header, the drawer and search results. Falls back to the first `#` heading, then to the id | +| `section` | string | Groups the article in the viewer tree and in the drawer's table of contents | +| `keywords` | list | Filter chips in the viewer, and the keyword map the assistant discovers topics through | +| `models` | list | Binds the article to model resources — **and gates access to it** (see below) | +| `urls` | list | Binds the article to admin pages: the "i" button and the `docs` prop of those pages | +| `accessRightsToken` | string | An **existing** access token required to read this article | +| `id` | string | Overrides the id derived from the file name | +| `locale` | string | Overrides the locale derived from the file name | + +## Translations + +A translation is a sibling file with a locale suffix: `intro.md` + `intro.ru.md` is one document +`intro` available in `en` and `ru`. The suffix is `xx` or `xx-YY` (`ru`, `pt-BR`). + +Not everything is translated per file: + +| Translated per file | Taken from the default-locale file | +|---|---| +| `title`, `keywords`, the body | `section`, `models`, `urls`, `accessRightsToken` | + +So structural metadata is edited in `intro.md` only — writing `models:` in `intro.ru.md` has no +effect as long as `intro.md` exists. + +Which translation a reader gets: the requested locale if it exists, otherwise the default locale, +otherwise whatever the document has. The response says which one it actually served, and the viewer +shows the locale chips only when the document has more than one translation. The requested locale is +the `locale` query parameter, or the reader's own locale, or `translation.defaultLocale` from the +config — in that order. + +## Binding an article to a place in the panel + +```markdown +urls: [/adminizer/model/:modelResourceName, /adminizer/model/:modelResourceName/edit/:id] +models: [Order] +``` + +`urls` are Express-style patterns matched against the **full admin path, route prefix included**: + +| Pattern | Matches | Does not match | +|---|---|---| +| `/adminizer` | `/adminizer`, `/adminizer/` | `/adminizer/model/Order` | +| `/adminizer/model/:name` | `/adminizer/model/Order` | `/adminizer/model/Order/edit/7` | +| `/adminizer/model/:name/edit/:id` | `/adminizer/model/Order/edit/7` | `/adminizer/model/Order` | + +A `:param` matches exactly one path segment; trailing slashes, the query string and the hash are +ignored. Because the route prefix is part of the pattern, articles bound by `urls` follow the +`routePrefix` of the project — bind by `models` when you want that not to matter. + +What the binding drives: + +* the **"i" button** in the page header — it appears when the current page has at least one bound + article the reader may open (or when the page is a model page, which always has a schema to show); +* the contextual table of contents in the drawer, and the `docs` shared prop behind it; +* scoped search: the assistant can ask for "documentation of this URL / this model" instead of + searching everything. + +`models` binds *and* gates: an article listing models is only visible to readers who may read at +least one of them. + +## Who may read the article + +Three checks, in this order: + +1. **`read-documentation`** — the base token. Without it the whole knowledge base is invisible. +2. **`accessRightsToken`** — when set, it is required on top. It must name a token that already + exists; the subsystem never creates tokens from articles, so a token nobody registered leaves the + article visible to administrators only. +3. **`models`** — when set, the reader needs `read--model` for at least one of them. + +An article the reader may not open does not exist for them: it is absent from the tree, from search +results and from the keyword map, and a link to it from another article renders as plain text. + +A custom per-article token is ordinary host or app code: + +```ts +adminizer.accessRightsHelper.registerToken({ + id: 'read-secret-operations-doc', + name: 'Secret operations doc', + description: 'Access to the "Secret operations" article', + department: 'documentation', +}); +``` + +## Links + +| Written as | Behaviour | +|---|---| +| `[Models](models-guide)` | Another article, by id. A link when the reader may open it, plain text when not | +| `[Models](models-guide#fields)` | Same, but the anchor is only used to find the article — it opens at the top | +| `[Fields](#fields)` | Anchor inside the current article | +| `[Orders](/adminizer/model/Order)` | Admin page, opened through Inertia without a full reload | +| `[Docs](https://docs.adminizer.org)` | External link, opens in a new tab | +| `![Diagram](https://…/diagram.png)` | Image by absolute URL — the documentation directory itself is not served, so relative image paths do not work | + +Write the bare id: `models-guide`, not `./models-guide` and not `models-guide.md` — the file name is +not the handle, the id is. + +All references of an article are resolved **on the server** in one request when the article opens, +so a link never reveals that an article the reader may not open exists. + +Anchors come from the headings: lowercased, every run of characters that are not letters or digits +becomes a dash. `## How a record travels` → `#how-a-record-travels`. + +## Diagrams and model schemas + +Two fenced blocks are rendered specially. + +A diagram — the mermaid library is loaded only when an article actually contains one, and follows +the panel's light/dark theme: + +````markdown +```mermaid +flowchart LR + A[List] --> B[Edit form] --> C[(Database)] +``` +```` + +The structure of a model — fetched through the API when the article is opened, so every reader sees +exactly the fields their own rights allow: + +````markdown +```schema +{"model": "Test"} +``` +```` + +A table that is not backed by a model can be written inline instead: + +````markdown +```schema +{"title": "Import file", "fields": [ + {"name": "sku", "title": "SKU", "type": "string", "required": true}, + {"name": "price", "title": "Price", "type": "number", "required": false} +]} +``` +```` + +Both degrade gracefully: a diagram that does not parse, invalid JSON, a model that does not exist or +one the reader may not read all fall back to showing the block as plain code. + +## Headings and the mini table of contents + +The viewer builds a small table of contents from the `##` and `###` headings and shows it when an +article has at least three of them; `#`…`###` also get anchors. Headings inside fenced code blocks +are ignored. In other words: one `#` title, then `##` sections, and long articles get navigation for +free. + +The rest is GitHub-flavoured markdown: tables, task lists, strikethrough, block quotes, images and +code fences (with a copy button, and the language shown in the header of the block). + +## Checking your work + +| Check | How | +|---|---| +| The article is there | Open `{routePrefix}/docs` — the tree is grouped by `section` | +| Search finds it | `{routePrefix}/docs?q=widgets`, or a keyword chip: `?keyword=models` | +| Keywords are right | `GET {routePrefix}/docs/api/keywords` | +| The binding works | Open the bound page and press "i", or `GET {routePrefix}/docs/api/context?url=/adminizer/model/Order` | +| Rights work | Open the panel as a user without the token: the article must be absent, not forbidden | +| The assistant sees it | Ask it to search the documentation, or use `read_documentation` with the id | + +With `watch: true` an edit is visible on the next page load. Without it, restart the app. + +## When something does not show up + +| Symptom | Usual cause | +|---|---| +| The article is missing for everyone | The file is outside `dir`, or is not `*.md`, or the app was not restarted without `watch` | +| The article is missing for one user | They lack `read-documentation`, the `accessRightsToken`, or `read--model` for the models it lists | +| It exists but nobody except admins sees it | `accessRightsToken` names a token that was never registered | +| A link renders as plain text | The id is misspelled, written as a path (`./id`, `id.md`), or the target is not readable by that reader | +| The "i" button does not appear | No `urls` pattern matches the real path — check the route prefix and the number of segments | +| A translation is ignored | The suffix is not a locale (`intro.v2.md`), or structural metadata was put in the translation instead of the default-locale file | +| Two articles overwrite each other | Two files share a base name in different subdirectories | +| A `schema` block shows its source | Invalid JSON, a model that does not exist, or no read rights for that model | diff --git a/docs/Layout Contexts.md b/docs/Layout Contexts.md new file mode 100644 index 00000000..8eebee6e --- /dev/null +++ b/docs/Layout Contexts.md @@ -0,0 +1,798 @@ +# Layout Contexts + +A **layout context** is a part of the admin panel that lives under its own URL prefix, with its own +menu and, optionally, its own layout. The panel itself is the built-in **default context**; apps and +the host add more. Once a user may enter more than one context, the brand menu at the top of the +sidebar turns into a **context switcher**. + +Typical uses: + +* a "System" area with its own short menu, keeping the panel's layout with a couple of parts changed; +* a "Support desk" with its own sidebar next to the stock header; +* a "Studio" with a shell of its own — a top bar, no sidebar at all. + +The feature is **opt-in**: a panel without registered contexts looks and behaves exactly as before. +Its pages only receive two extra shared props, `adminContext` and `adminContexts`; the menu and the +layout are the stock ones, and no switcher is shown. + +--- + +## Enabling the feature + +### 1. Register a context + +An app registers its context in `setup()`; the context lives as long as the app is enabled. + +```ts +import path from "path"; +import {AbstractAdminizerApp, AppAsset, AppSetupContext} from "adminizer"; + +const asset = (id: string, name: string): AppAsset => ({ + id, + filePath: path.resolve(import.meta.dirname, `${name}.es.js`), + devUrl: `/apps/support/${name}.tsx`, +}); + +export class SupportApp extends AbstractAdminizerApp { + readonly name = "support"; + readonly version = "1.0.0"; + + setup(ctx: AppSetupContext): void { + const support = ctx.context({ + id: "support", + title: "Support desk", + icon: "support_agent", + prefix: "/support", + accessRightsToken: "context-support", + layout: {module: asset("layout", "SupportLayout")}, + }); + // ...pages, step 2 + } +} +``` + +### 2. Add its pages + +`page()` registers a route under the context prefix, a menu item and breadcrumbs in one call: + +```ts +const page = asset("page", "SupportPage"); + +support.page({id: "inbox", title: "Inbox", icon: "inbox", path: "", component: page}); +support.page({id: "tickets", title: "Open tickets", path: "/tickets", badge: 7, component: page}); +``` + +### 3. Write the layout module + +The module exports the parts of the layout it replaces — here the sidebar; everything else stays +stock: + +```tsx +// SupportLayout.tsx +import {Link} from "@inertiajs/react"; +import {Sidebar as UISidebar, SidebarContent, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem} from "@/components/ui/sidebar"; +import {ContextSwitcher, type SlotProps, useAdminContext} from "@/shell"; + +export function Sidebar(_: SlotProps) { + const {menu, isActive} = useAdminContext(); + return ( + + + + + {menu.map((item) => ( + + + {item.title} + + + ))} + + + + ); +} +``` + +Build it as an ES module with `@/shell` mapped to `window.AdminizerShell`, see [Vite build](#vite-build). +That is all: users holding `context-support` get the switcher, and `/support` opens with its own +sidebar and menu. + +--- + +## How it works + +### What a context decides + +Every page request belongs to exactly one context, and the context decides three things: + +* **the menu** — the `menu` and `menuSections` shared props are built from the context's navbar; +* **the layout** — the shell drawn around the page (`adminContext.layout`); +* **access** — for pages declared to belong to the context, only users who may enter it get through. + +### How a page gets its context + +`ContextHandler` (`adminizer.contextHandler`) applies four rules, the first match wins: + +1. **A declared page** renders in the context it is declared to belong to, wherever the user came + from. See [Pages declaring their context](#pages-declaring-their-context). +2. **A URL under a context prefix** belongs to that context. Nested prefixes (`/ops` and + `/ops/deep`) resolve to the longest one. Prefixes match whole path segments: `/studiox` is not + inside `/studio`. +3. **Any other URL** — the built-in `/model/...` pages, for one — stays in the context the user came + from, as long as that context's navbar links to the URL or to a parent path of it. The context + the user came from is kept in the `adminizer_context` cookie. +4. Everything else belongs to the **default context**. + +Under rules 2 and 3 a context the user may not enter is never resolved — the request falls back to +the default context. Under rule 1 the page is blocked instead. + +### A request, end to end + +```text +GET {routePrefix}/model/JsonSchema + ↓ bindInertia the user, shared props (context-related ones are lazy) + ↓ contextHandler.guard() declared to "system"? no access → login / 403 / 404 + ↓ route policies auth, model tokens — as for any page + ↓ controller renders the page + ↓ Inertia render adminContext, adminContexts, menu, menuSections are computed, + once; the visit is remembered in the cookie + → browser ContextLayoutHost loads the context's layout modules and draws the shell +``` + +Two properties fall out of that order: + +* **Context props cost nothing outside page renders.** API calls never compute them, and a partial + reload computes only the props it asks for. +* **A blocked page never runs its handler**, whatever the method: the guard answers before the route. + +### The context cookie + +The context of a page visit is remembered in the `adminizer_context` cookie (HttpOnly, +`SameSite=Lax`, path `routePrefix`). It is written only: + +* when a page is rendered for a `GET` page visit — an Inertia request or a request accepting + `text/html` — never for API calls, asset requests or form submissions; +* when the user may enter more than one context; +* when the context changes. + +The cookie only matters for undeclared URLs outside every context prefix (rule 3). An unknown or +stale value is ignored. + +--- + +## Registering a context + +### From an app + +`ctx.context(resource)` registers the context and returns its scope (`id`, `page()`, `controller()`, +`menuItem()`, see [Pages of a context](#pages-of-a-context)). Layout modules and page components +are app assets (`AppAsset`), as in `ctx.asset()`: `filePath` is served in production, `devUrl` is +used with `ADMINIZER_ENV=dev`. An asset used by several pages — of one context or of several — is +registered once. The context, its pages and its routes are removed when the app is disabled or +unregistered. + +One app may register several contexts. + +### From the host + +The host registers on the handler directly; `register()` returns the same scope, and the pages it +adds are the host's controllers (owner `host`). Layout modules and page components are URLs, +registered through the same asset handler apps use: + +```ts +const host = (asset: AppAsset) => adminizer.assetHandler.register("host", asset); + +adminizer.accessRightsHelper.registerToken({ + id: "context-reports", name: "Reports", description: "Access to the Reports context", department: "Contexts", +}); + +const reports = adminizer.contextHandler.register({ + id: "reports", + title: "Reports", + icon: "bar_chart", + prefix: "/reports", + home: "/reports/summary", + accessRightsToken: "context-reports", + layout: { + module: host({id: "reports-overrides", filePath: path.resolve(import.meta.dirname, "ReportsShellOverrides.es.js")}), + stylesheet: host({id: "reports-css", filePath: path.resolve(import.meta.dirname, "ReportsShellOverrides.css")}), + }, + navbar: {sections: {Reports: {icon: "bar_chart", order: 1}}}, +}); + +const page = host({id: "reports-page", filePath: path.resolve(import.meta.dirname, "ReportsPage.es.js")}); +reports.page({id: "summary", title: "Summary", icon: "summarize", path: "/summary", component: page}); + +// Later, if needed: +adminizer.contextHandler.unregister("reports"); +``` + +The token is given once — to the context. Its pages need no permission policy of their own: they +belong to the context, and the context token is checked for them. + +### Context options + +| Option | Type | Default | Meaning | +|---|---|---|---| +| `id` | `string` | required | Unique id. `default` is reserved for the built-in context. | +| `title` | `string` | required | Shown in the switcher and as the default navbar section. Translated through i18n. | +| `icon` | `string` | `rocket_launch` in the switcher | Material icon name. | +| `prefix` | `string` | required | Path below `routePrefix`, e.g. `/support`. A missing leading slash is added, trailing slashes are removed. | +| `home` | `string` | the prefix | Where the switcher leads, as a path below `routePrefix`. | +| `layout` | see [Layouts](#layouts) | `"inherit"` | The shell drawn around the context's pages. | +| `navbar` | `"inherit"` or `{ items?, sections? }` | inherited | The context's menu, see [Navbar](#navbar). | +| `accessRightsToken` | `string` | none | Users without the token neither see nor enter the context, and may not open its declared pages. | +| `switcher` | `boolean` | `true` | `false` keeps the context out of the switcher; its URLs still resolve to it. | + +`register()` throws when `id` or `title` is empty, `id` is `default` or already registered, or +`prefix` is empty or already used by another context. + +--- + +## Pages of a context + +### `page()` + +`page(page)` registers a route at `prefix + path`, appends a menu item to the context's navbar and +returns that item (`HrefConfig`; its `link` is the full path, `routePrefix` included). The route is +declared to belong to the context: it renders in it, and users who may not enter the context get +403. + +| Option | Type | Default | Meaning | +|---|---|---|---| +| `id` | `string` | required | Unique within the context; also the id of the menu item. | +| `path` | `string` | required | Path below the context prefix. `""` is the prefix itself. | +| `title` | `string` | required | Menu item, page title and last breadcrumb. Translated through i18n. | +| `icon` | `string` | none | Material icon of the menu item. | +| `component` | URL, or `AppAsset` in apps | — | ES module whose default export renders the page. | +| `props` | object or `(req) => object` | `{}` | Props of `component`, computed per request if a function. | +| `middleware` | `MiddlewareType` | — | A handler of its own instead of `component`. Exactly one of the two is required. | +| `method` | `AppControllerMethod` | `"get"` | HTTP method. | +| `policies` | `AppControllerPolicy[]` | `auth` | Route policies. `[]` leaves only the context token. | +| `menu` | `boolean` | see below | Add a menu item. | +| `section`, `badge`, `accessRightsToken` | | none | Passed to the menu item. | + +A `component` page is rendered as the `module` page with: + +* `title` — `" · "`; +* `breadcrumbs` — ` › `, the first crumb linking to the context prefix; +* everything from `props`, which wins over the two above. + +### `controller()` and `menuItem()` + +`controller(controller)` registers a route at `prefix + path` that belongs to the context but has no +menu item — an API of the context, a form handler — and returns its full path. It takes the options +of `ctx.controller()` with `path` instead of `route`; its default policy is `auth` (`ui` mode for +`get`, `api` otherwise). + +```ts +support.controller({id: "reply", method: "post", path: "/tickets/:id/reply", middleware: replyHandler}); +``` + +`menuItem(item)` appends any `HrefConfig` to the context's navbar — a link to a built-in page, or a +group whose `subItems` are pages added with `menu: false`: + +```ts +const daily = reports.page({id: "daily", title: "Daily sales", path: "/sales/daily", menu: false, component: page}); +const monthly = reports.page({id: "monthly", title: "Monthly sales", path: "/sales/monthly", menu: false, component: page}); +reports.menuItem({id: "sales", title: "Sales", icon: "payments", link: daily.link, type: "self", subItems: [daily, monthly]}); +``` + +### Which menu the items go to + +| `navbar` | `page()` and `menuItem()` | +|---|---| +| left out | The context shows the default menu until the first item is added; from then on the menu is its own. | +| `{ items?, sections? }` | Items are appended after `items`. | +| `"inherit"` | `page()` adds no item; `menu: true` and `menuItem()` throw. | + +Pages are optional. A context may as well be filled with routes registered elsewhere and +`navbar.items` given at registration, as long as their URLs start with the prefix — such routes +belong to the context by rule 2, without the access check of declared pages. + +--- + +## Navbar + +`navbar.items` is a list of `HrefConfig`, the shape of `config.navbar.additionalLinks`: `id`, +`title`, `link`, `type`, `icon`, `section`, `badge`, `accessRightsToken`, `subItems`. + +* `link` is a full path, `routePrefix` included; `page()` and `ctx.controller()` return one. +* An item without tokens is shown to everyone. Otherwise it is shown when the user holds its + `accessRightsToken` or the token of one of its sub-items; sub-items the user may not open are + removed. +* Items leading to a declared page the user may not open are removed as well. +* Items without a `section` are grouped under the context title. `navbar.sections` sets the icon and + order of section headers, as `config.navbar.sections` does for the default context. +* The stock menu highlights only the item with the most specific link matching the URL, so a context + home at the prefix itself is not highlighted on the other pages of the context. + +### Built-in pages in a context menu + +A context menu may link to built-in pages: + +```ts +system.menuItem({ + id: "categories", title: "Categories", icon: "category", + link: `${routePrefix}/model/Category`, type: "self", section: "Data", + accessRightsToken: "read-Category-model", +}); +``` + +Opened from this context, `/model/Category` is drawn in the context's layout with its menu — and so +is every URL below it (`/add`, `/edit/:id`), so create and edit flows stay inside the context. +Opening a page the context menu does not link to returns the user to the default context. + +This follows the **user**, not the page (rule 3): in a second tab, from a bookmark or from a link a +colleague sent, the same URL may open in another context. To pin a page to one context, declare it. + +--- + +## Pages declaring their context + +A page may declare the context it belongs to. A declared page always renders in its context, and +only users who may enter that context may open it. Pages that declare nothing behave as described +above — declaring is opt-in per page. + +### Where a context is declared + +| Where | How | Covers | +|---|---|---| +| A route of an app or of the host | `context: "support"` in `ctx.controller()` or `controllerHandler.register()`; `page()` and `controller()` of a scope set it | That route exactly; `:params` match one segment. | +| The pages of a model | `context: "system"` in the model config | `/model/` and everything below it: list, add, edit, view, filters, export, inline edit. | +| Anything else — catalogs, custom routes | `adminizer.contextHandler.assign(path, contextId, {exact?})` | `path` and everything below it, or exactly `path`. Returns a function removing the declaration. | + +```ts +// adminizer config +models: { + JsonSchema: { + title: "Json schema", + model: "jsonschema", + context: "system", + }, +} +``` + +The most specific declaration wins: a route declared to `system` inside a path assigned to `support` +belongs to `system`. A visit to a declared page is remembered in the cookie like any other, so the +undeclared built-in pages opened from it stay in its context. + +### Blocking + +A declared page the user may not open is answered before any handler, for every method, API calls +included: + +| Case | Answer | +|---|---| +| Not logged in | The login page, as with the `auth` policy; 401 for API calls. | +| No context token | 403. | +| The context is not registered — a typo, a disabled app | 404 for everyone, and an error in the log once per context. | +| `auth.enable: false` | Passes, as every policy does. | + +Pages are told from API calls by the request: an Inertia request or one accepting `text/html` gets +the page answers (login redirect, `403`), anything else gets JSON. + +Login, logout, registration (`/model//login`, `/logout`, `/register`) and the first-user setup +(`/init_user`) never belong to a context, so declaring a context on the `User` model cannot lock +anyone out. + +### Before declaring a context + +* **Declaring a context on a model is a breaking change** for users without the context token: every + `/model//...` route answers 403 for them, including those other screens use — related-record + dialogs, catalogs, saved filters, CKEditor uploads. The "create" button of a relation field is + hidden for them. +* Links to blocked pages are removed from the default menu, context menus, global search and the AI + assistant's link search and templates. +* A default-menu item leading to a declared model switches the shell to the model's context. A model + that should live in several contexts does not declare one. +* A context that may disappear makes the pages declared to it answer 404 while it is gone. Routes and + models of the app that owns the context go away together with it; a host model declaring an app's + context does not. + +--- + +## Layouts + +`layout` is one of: + +| Value | Shell | +|---|---| +| `"inherit"` | The default context's layout, with its overrides. The default for every context; for the default context itself, the stock layout. | +| `"stock"` | The stock sidebar and header, without any override. | +| `"none"` | No shell: the page fills the window. | +| `{ module, stylesheet?, base? }` | The inherited layout with some of its parts — or the whole of it — replaced by the module's named exports. `base: "stock"` applies the module over the stock layout instead of the inherited one. | + +Module and stylesheet references are URLs when the host registers the context, and `AppAsset`s in +`ctx.context()`. + +Some things are shared by every layout, the default one included: the notification, AI assistant and +relation dialog providers, the assistant viewport, the Toaster and the saved light or dark theme. +Switching between contexts is an ordinary Inertia visit, and none of these is re-created. + +### Overriding parts of the layout + +`layout.module` points to an ES module whose **named exports** replace parts (slots) of the layout. +Parts the module does not export stay as they are. + +| Slot | Part | Props | +|---|---|---| +| `Layout` | The whole layout. | `children`, `breadcrumbs`, `className`, `context` | +| `Sidebar` | The whole sidebar. | — | +| `SidebarHeader` | Content of the sidebar header: the context switcher or the brand menu. | — | +| `SidebarContent` | Content of the sidebar body: the menu (`NavMain`). | — | +| `SidebarFooter` | Content of the sidebar footer: version and feedback. | — | +| `Header` | The whole header. | `breadcrumbs` | +| `HeaderStart` | Left part of the header: sidebar toggle and breadcrumbs. | `breadcrumbs` | +| `HeaderActions` | Right part of the header: search, assistant, docs, history, theme, notifications, user menu. | `breadcrumbs` | + +`SidebarHeader`, `SidebarContent` and `SidebarFooter` replace the content of their section; the +section element itself (spacing, collapse behaviour) stays. The same goes for `HeaderStart` and +`HeaderActions` inside the stock header. `Layout`, `Sidebar` and `Header` replace the element as a +whole, and the slots inside it are then not used unless the override renders `Default` or stock +parts that contain them (`DefaultLayout`, `AppSidebar`, `AppSidebarHeader`). + +Every override receives the slot's props plus `Default` — the part it replaces. Render `Default` to +wrap or extend the part instead of rewriting it: + +```tsx +// SystemShellOverrides.tsx +import {Link} from "@inertiajs/react"; +import {MaterialIcon, type SlotProps, useAdminContext} from "@/shell"; + +// An extra button before the inherited header actions. +export function HeaderActions({Default, ...props}: SlotProps) { + const {context} = useAdminContext(); + return ( + <> + Settings + + + ); +} + +// A note under the inherited footer. +export function SidebarFooter({Default}: SlotProps) { + return ( + <> + +
System context
+ + ); +} +``` + +### Inheritance + +Layers stack along the chain: the default context's layers (see [The default context](#the-default-context)) +apply first, the context's own module on top of them. In a context override, `Default` is the part +as the default context draws it, which is not necessarily the stock part. In the fixture the host +adds a badge to the default sidebar footer, and System appends a note to it: System's footer shows +the badge, the stock version line and the note. + +| Context `layout` | Layers applied | +|---|---| +| `"inherit"` or left out | The default context's, over its root. | +| `{ module }` | The default context's, then the context's own. | +| `{ module, base: "stock" }` | Only the context's own, over the stock layout. | +| `"stock"` | None: the clean stock layout. | +| `"none"` | None, and no shell. | + +The rule holds whatever the default context is. If it replaces the whole layout (`Layout`), a +context module overriding `SidebarFooter` applies wherever that layout draws the stock sidebar. + +### Writing a whole layout + +Export `Layout` from the module. It receives: + +| Prop | Meaning | +|---|---| +| `children` | The page. | +| `breadcrumbs` | Breadcrumbs given by the page, or the server-generated ones. | +| `className` | Class name passed by the page. | +| `context` | The current context (`AdminContextInfo`). | +| `Default` | The layout it replaces; render it to wrap the inherited layout. | + +Use `base: "stock"` when none of the default context's layers should apply. + +```tsx +// StudioLayout.tsx +import {Link, usePage} from "@inertiajs/react"; +import {type LayoutProps, useAdminContext} from "@/shell"; + +export function Layout({children}: LayoutProps) { + const {logout, logoutBtn} = usePage().props; + const {context, contexts, menu, isActive, switchTo} = useAdminContext(); + return ( +
+
+ {menu.map((item) => ( + {item.title} + ))} + + {logoutBtn} +
+
{children}
+
+ ); +} +``` + +`NavUser` and `AppSidebarHeader` need `AppShell` around them, so a layout without it draws the user +name and the logout link (`logout`, `logoutBtn` props) on its own. + +### Reusing stock parts + +The stock shell parts are published as `window.AdminizerShell`. Import them from `@/shell` and map +that import to the global in the Vite build: + +| Export | What it is | +|---|---| +| `useAdminContext()` | The context of the page, see [Frontend API](#frontend-api). | +| `AppShell` | Sidebar state provider and frame; required by every `Sidebar*` component, `AppSidebarHeader` and `NavUser`. | +| `DefaultLayout` | The whole stock layout (sidebar + header). | +| `AppSidebar` | The stock sidebar with the context menu. | +| `AppSidebarHeader` | The stock header. | +| `ContextSwitcher` | The context switcher dropdown; works with or without `AppShell`. Prop `side`: `"bottom"` (default) or `"right"`; on mobile it always opens downwards. | +| `NavMain` | The stock menu renderer. | +| `NavUser`, `UserInfo`, `UserMenuContent` | User menu parts. | +| `Breadcrumbs`, `GlobalSearch`, `ThemeSwitcher`, `MaterialIcon` | Individual header parts. | + +Types: `SlotProps

` (an override's props, `Default` included), `LayoutProps`, `ShellSlotName`, +`AdminContextInfo`, `AdminContextLayer`. + +The `Sidebar*` primitives come from `window.UIComponents` (`@/components/ui/sidebar`), see +[Global UI Components](GlobalUI.md). + +### Loading and errors + +The page is shown once every module of the context has loaded. On the first visit to a context a +spinner is shown meanwhile; later the modules come from the cache and switching is instant. +Stylesheets are added to `` once. + +* **A module that fails to load** is skipped and the error is logged to the console. Its parts fall + back to the parent's version and the other layers still apply; a context whose only module failed + gets the stock layout. +* **An override that throws while rendering** is replaced by the part it overrides (its `Default`), + and the error is logged to the console. The rest of the page keeps working. +* **A layout the browser does not recognize** is drawn as the stock layout. + +A context stylesheet loads after the panel's, so its rules win over Tailwind utilities of the same +specificity: `display: flex` on a class beats `group-data-[state=collapsed]:hidden` on the same +element. Hide such elements in the collapsed sidebar with a rule of your own, e.g. +`[data-state="collapsed"] .my-filter { display: none; }`. + +### Vite build + +Build a layout module like any app component (see +[App Modules](BuildingModules.md#vite-build-for-app-components)) and add `@/shell` to the externals: + +```ts +const externals = { + react: "React", + "react-dom": "ReactDOM", + "@inertiajs/react": "InertiajsReact", + "@/components/ui/sidebar": "UIComponents", + "@/shell": "AdminizerShell", +}; + +export default defineConfig({ + plugins: [react(), viteExternalsPlugin(externals)], + build: { + lib: {entry: "SupportLayout", formats: ["es"], fileName: (format) => `SupportLayout.${format}.js`}, + rollupOptions: { + external: Object.keys(externals).filter((name) => name.startsWith("@/")), + }, + }, + resolve: {alias: {"@": path.resolve(import.meta.dirname, "/src/assets/js")}}, +}); +``` + +--- + +## The default context + +The built-in context has the id `default` and covers every URL no other context claims. Its title is +the brand title, its prefix the root of `routePrefix`. It cannot be unregistered or disabled: +`configureDefault({switcher: false})` only removes it from the switcher. + +The host changes its title, icon, home, layout, navbar or switcher visibility with +`configureDefault()`; repeated calls are merged. Contexts with an inherited layout or menu inherit +these changes. + +```ts +// A part of the default layout overridden; contexts inheriting the layout get it too. +const module = adminizer.assetHandler.register("host", { + id: "default-shell-overrides", + filePath: path.resolve(import.meta.dirname, "DefaultShellOverrides.es.js"), + devUrl: "/fixture/apps/contexts/DefaultShellOverrides.tsx", +}); +adminizer.contextHandler.configureDefault({layout: {module}}); +``` + +```ts +// The whole default layout and menu replaced. +adminizer.contextHandler.configureDefault({ + title: "Back office", + icon: "home", + layout: {module: "/static/BackOfficeLayout.es.js"}, // exports `Layout` + navbar: { + items: [{id: "orders", title: "Orders", link: "/admin/model/Order", type: "self", icon: "receipt"}], + sections: {Sales: {icon: "sell", order: 1}}, + }, +}); +``` + +A replaced navbar replaces the menu built from `config.navbar` and app links. + +--- + +## Access rights + +* A context with an `accessRightsToken` is hidden from the switcher for users without the token. Its + prefix resolves to the default context for them, so they get neither its menu nor its layout. +* **For declared pages the context token is an access check**: users without it get 403. Pages added + with `page()` and `controller()` are declared. +* **For other pages it is not.** A route under the prefix registered without `context`, or a built-in + page kept in the context by the cookie, is protected by its own policies and model tokens only. + Protect such routes, or declare them. +* Menu items are filtered by their tokens and by the declared context of their links, see + [Navbar](#navbar). + +The context token is an ordinary access rights token: register it with +`accessRightsHelper.registerToken()` (or `ctx.accessRight()` in an app) and grant it to groups as +usual. + +--- + +## Switcher + +The switcher replaces the brand menu of the stock sidebar when the user may enter more than one +context. It lists: + +1. the contexts of the `adminContexts` shared prop — accessible ones with `switcher !== false` — with + a check mark on the current one; +2. below a separator, the links of `config.sections`, which the brand menu used to show. + +Selecting a context is a visit to its `link`: the prefix, or `home`. + +--- + +## Frontend API + +### Shared props + +Every Inertia page receives: + +```ts +interface AdminContextInfo { + id: string; + title: string; // translated + icon: string | null; + prefix: string; // full prefix, routePrefix included + link: string; // where the switcher leads + layout: { + root: "stock" | "none"; + // Module URLs in order, the default context's first. `default: "Layout"` + // marks a module whose default export is the whole layout. + layers: {module: string; default?: "Layout"}[]; + stylesheets: string[]; + }; +} + +// SharedData +adminContext?: AdminContextInfo; // the context of this page +adminContexts?: AdminContextInfo[]; // contexts shown in the switcher; one entry means no switcher +``` + +`menu` and `menuSections` already belong to `adminContext`. The four props are computed once per +request, and only when a page is rendered. + +### `useAdminContext()` + +The hook (from `@/shell`) reads the context of the page in layouts, overrides and page components: + +| Field | Meaning | +|---|---| +| `context` | `adminContext`. | +| `contexts` | `adminContexts`. | +| `menu`, `sections` | `menu` and `menuSections` of the context. | +| `isActive(link)` | Whether `link` is the active menu link — the most specific one matching the URL, as in the stock menu. | +| `switchTo(id)` | Visits the context's `link`. | + +--- + +## Server API + +`adminizer.contextHandler` (`ContextHandler`, exported from `adminizer`): + +| Method | Purpose | +|---|---| +| `register(context, owner = "host")` | Add a context; returns its scope. | +| `unregister(id, owner?)` | Remove a context; with `owner`, only that owner's one. | +| `scope(id, registrar)` | A scope of a registered context that registers routes and components through `registrar`; `ctx.context()` uses it for the app's own routes and assets. | +| `configureDefault(options)` | Change title, icon, home, layout, navbar or switcher of the default context. | +| `get(id)`, `getDefault()`, `list()` | Read contexts; `list()` starts with the default one. | +| `listAccessible(user)` | Contexts the user may enter, the default one first. | +| `assign(path, contextId, {exact?, owner?})` | Declare the context of the pages at `path`; returns a function removing the declaration. | +| `declaredFor(url)` | The declared context of a page, if any. | +| `isBlocked(url, user)` | Whether the page belongs to a context the user may not enter. | +| `guard()` | The middleware blocking such pages; Adminizer mounts it before all routes. | +| `resolve(req)` | The context of a request. | +| `resolveLayout(context)` | The layout with inheritance applied, as sent in `adminContext.layout`. | + +The scope returned by `register()` and `ctx.context()`: + +| Member | Purpose | +|---|---| +| `id` | The context id. | +| `page(page)` | A page: route, menu item, breadcrumbs; returns the menu item. | +| `controller(controller)` | A route under the prefix without a menu item; returns its full path. | +| `menuItem(item)` | Appends an item to the context's menu. | + +Exported types: `AdminContext`, `AdminContextLayout`, `AdminContextNavbar`, `AdminContextPage`, +`AdminContextController`, `AdminContextScope`, `AdminContextRegistrar`, `AdminContextProps`, +`AdminContextLayoutProps`, `AdminContextLayerProps`, `DefaultAdminContextOptions`, `ShellSlotName`, +`AppContextResource`, `AppContextScope`, and the constant `DEFAULT_ADMIN_CONTEXT_ID`. + +### Events + +| Event | Payload | +|---|---| +| `app:context:registered` | `{ appName, resourceId, id, prefix }` | +| `app:context:unregistered` | `{ appName, resourceId, id }` | + +`appName` is the owning app, or `host` for contexts registered with `contextHandler.register()`. + +--- + +## Compatibility + +Earlier layout values keep working and draw exactly what they did: + +| Earlier value | Same as | +|---|---| +| `"default"` | `"stock"` | +| `"bare"` | `"none"` | +| `{ overrides, stylesheet? }` | `{ module, stylesheet? }` | +| `{ component, stylesheet? }` | A module whose default export is the whole layout (`Layout`), over the stock layout, without the parent's layers. | + +Routes, models and menus that do not use contexts are not affected: without registered contexts and +declarations every page resolves to the default context, the menu and layout are the stock ones, +the guard lets every request through and no cookie is written. + +--- + +## Fixture example + +`fixture/apps/contexts` holds the demo contexts, set up in `fixture/index.ts`; the code is in +`ContextDemoApps.ts`. + +| Context | Registered by | URL | Shows | +|---|---|---|---| +| Default | host, `configureDefaultContext()` | `/adminizer` | `configureDefault({layout: {module}})`: a badge in the sidebar footer (`DefaultShellOverrides.tsx`). | +| Reports | host, `registerReportsContext()` | `/adminizer/reports` | `contextHandler.register()` with `page()`: inherited layout with `Sidebar`, `SidebarHeader`, `SidebarContent`, `Header` and `HeaderStart` overridden plus a `stylesheet` (`ReportsShellOverrides.tsx`, `.css`); `home`; `accessRightsToken` — hidden from users without `context-reports`, its pages answer 403 to them; a `menuItem()` group with `subItems`. | +| System | app `context-system` | `/adminizer/system` | Inherited layout with `HeaderActions` and `SidebarFooter` overridden (`SystemShellOverrides.tsx`), the footer wrapping the default context's override. Own menu with sections; `/model/Category` kept in the context by the cookie, and `/model/JsonSchema`, whose model declares `context: "system"` (`fixture/adminizerConfig.ts`), always opening in it. | +| Support desk | app `context-support` | `/adminizer/support` | `{module}` overriding `Sidebar` only: own sidebar with a badge next to the stock header (`SupportLayout.tsx`). | +| Studio | app `context-studio` | `/adminizer/studio` | `{module, base: "stock"}` exporting `Layout`, written from scratch: a top bar, no sidebar (`StudioLayout.tsx`). | +| Inherited | app `context-variants` | `/adminizer/inherited` | Neither `layout` nor `navbar`: the default context's menu and overrides; its page uses `menu: false`. | +| Plain | app `context-variants` | `/adminizer/plain` | `layout: "stock"`: the stock layout without the default context's overrides. | +| Kiosk | app `context-variants` | `/adminizer/kiosk` | `layout: "none"` and `switcher: false`: no shell, reached only through a link on the Inherited page. | + +Build the bundles with `npm run build:context-apps` (part of `npm run build:apps`). Replacing the +title, menu or whole layout of the default context is left out of the fixture, since it would change +the whole demo panel. + +--- + +## Limitations + +* Global search (`Ctrl/Cmd+K`) and the AI assistant's link search hide pages the user may not open, + but otherwise are not context-aware: they search the whole navigation registry, and an undeclared + page they lead to opens in the context the cookie points to. +* `window.routePrefix` stays the panel root and the API base; contexts do not change it. +* Contexts do not nest: a context under another context's prefix inherits its layout from the default + context, not from the outer one. diff --git a/docs/MediaManager.md b/docs/MediaManager.md new file mode 100644 index 00000000..96a0394e --- /dev/null +++ b/docs/MediaManager.md @@ -0,0 +1,145 @@ +# Media Manager + +The media manager has two layers in Adminizer v5: + +- Core owns the legacy HTTP transport, upload adapter, thumbnail endpoint, public-file binding, and file icons. +- An app owns the storage models and the `AbstractMediaManager` implementation. + +Adminizer no longer creates media manager ORM models. The fixture implementation in `fixture/apps/media-manager` is the Sequelize reference. + +## App Registration + +A media manager app declares its access right, existing models, scoped model access, and manager factory: + +```ts +setup(ctx: AppSetupContext): void { + ctx.accessRight({ + id: "mediaManager-default", + name: "default", + description: "Access to edit the default media manager", + department: "media-manager", + }); + + ctx.model({ name: "MediaManagerAP", adapter: "sequelize" }); + ctx.model({ name: "MediaManagerMetaAP", adapter: "sequelize" }); + ctx.model({ name: "MediaManagerAssociationsAP", adapter: "sequelize" }); + + ctx.modelAccess({ + models: [ + "MediaManagerAP", + "MediaManagerMetaAP", + "MediaManagerAssociationsAP", + ], + }); + + ctx.mediaManager({ + factory: (runtime) => new ProjectMediaManager(runtime, config), + }); +} +``` + +`AppManager` creates the manager after model bindings and model access are ready. Disabling the app unregisters the manager, but it does not remove native ORM models or database tables. + +## Sequelize Setup + +Sequelize app models can be installed after Adminizer startup and immediately before the app is enabled: + +```ts +await adminizer.init(adminpanelConfig); + +await installMediaManagerSequelizeModels(sequelize, true); +await adminizer.appManager.enable( + new MediaManagerApp(adminpanelConfig.mediamanager!) +); +``` + +The installer and app above are fixture reference code, not automatically registered package models. A host project should provide an equivalent installer, migrations, and app implementation. + +The fixture models are: + +- `MediaManagerAP` +- `MediaManagerMetaAP` +- `MediaManagerAssociationsAP` + +They are app-owned and are not part of the required system model contract described in [System Models](Configuration/Models.md). + +## TypeORM + +TypeORM support remains experimental. The core system models can be used with TypeORM, but app entities cannot be added dynamically after `DataSource.initialize()`. + +To implement a TypeORM media manager app: + +1. Define the media manager entities in the host project. +2. Add them to `DataSource.entities` before `initialize()`. +3. Initialize the data source and Adminizer. +4. Enable the app that attaches those existing entities. + +The fixture does not currently enable its media manager app for TypeORM. Core logs a warning when an app is enabled while `system.defaultORM` is `typeorm`. + +## Core Transport In Version 5 + +The following compatibility endpoints and resources remain in core: + +- `${routePrefix}/media-manager-uploader/:id` +- `${routePrefix}/media-manager-uploader/:id/upload` +- `${routePrefix}/media-manager-uploader/:id/upload-variant` +- `${routePrefix}/get-thumbs` +- `${routePrefix}/fileicons` +- `/public` when `bind.public` is enabled and `mediamanager.fileStoragePath` is configured + +The controllers currently resolve managers through `req.adminizer.mediaManagerHandler`. Moving this transport to the system runtime is deferred to v6. + +`npm run copy:backend` copies `src/fileicons` to `dist/fileicons`, so production startup from `dist` uses the built resource path. + +## Legacy Registration + +Custom media managers that are not apps remain supported during v5: + +```ts +class CustomMediaManager extends AbstractMediaManager { + constructor(adminizer: Adminizer) { + super(adminizer); + } + + // Implement the abstract media manager methods. +} + +const manager = new CustomMediaManager(adminizer); +adminizer.mediaManagerHandler.add(manager); +``` + +The `AbstractMediaManager` constructor calls the legacy `_bindAccessRight()` path. Both the constructor dependency and direct `mediaManagerHandler.add()` registration are deprecated and planned for removal in v6. + +An app-owned manager should register its token with `ctx.accessRight()`. Until the base constructor changes in v6, it can pass a no-op legacy host to `super(...)`; `fixture/apps/media-manager/DefaultMediaManager.ts` shows this compatibility pattern. + +## Configuration + +Core transport still reads `AdminpanelConfig.mediamanager`: + +```ts +mediamanager: { + fileStoragePath: ".tmp/public", + allowMIME: ["image/*", "application/*", "text/*", "video/*"], + maxByteSize: 2 * 1024 * 1024, + imageSizes: { + sm: { width: 350, height: 350 }, + lg: { width: 750, height: 750 }, + }, +}, +bind: { + public: true, +}, +``` + +Fields using the media manager select a registered manager by id: + +```ts +media: { + type: "mediamanager", + options: { + id: "default", + group: "images", + accept: ["image/jpeg", "image/png"], + }, +}, +``` diff --git a/docs/Navigation.md b/docs/Navigation.md index 425cb770..e8c5bbd8 100644 --- a/docs/Navigation.md +++ b/docs/Navigation.md @@ -1,333 +1,415 @@ -# Navigation Catalog +# Navigation Module -## Abstract +Navigation is implemented as an Adminizer app module in the fixture: `fixture/apps/navigation`. Use that implementation as the reference for building project-specific navigation editors. -The Navigation Catalog is a built-in Adminizer system for managing hierarchical site navigation menus — header, footer, or any other sections. It is built on top of the [Catalog](Catalog.md) infrastructure and adds specialized item types: records from any configured model (linked via a URL template), free-form groups with custom metadata fields, and plain external links. +The module is built on the catalog system and registers its own runtime model, catalog, template components, access token, sidebar links, and synchronization listener. -**Why is this needed:** -- **Visual tree editor**: drag-and-drop ordering and nesting in the admin panel UI -- **Multiple sections**: manage header, footer, and any other menus independently in one place -- **Mixed content**: model records, groups, and external links coexist in the same tree -- **URL templating**: item URLs are generated automatically from model record fields -- **Persistent storage**: the entire tree is stored as JSON in the database, one row per section -- **Ready-made API**: read the navigation tree from your frontend application via a single query +## Fixture Files -**Main features:** -- Add records from any configured model as navigation items -- Add folder-like groups with custom extra fields -- Add manual external links -- Drag-and-drop reordering and re-nesting -- Per-item `visible` and `targetBlank` flags -- Full-text search across all items -- No extra code required — enabled entirely through `adminizerConfig.ts` +| File | Purpose | +|---|---| +| `fixture/apps/navigation/NavigationApp.ts` | App entry point. Registers all module resources through `AppSetupContext`. | +| `fixture/apps/navigation/navigationConfig.ts` | Example module config used by the fixture. | +| `fixture/apps/navigation/NavigationTypes.ts` | Config interfaces. | +| `fixture/apps/navigation/NavigationModel.ts` | Runtime model name and schema for persisted trees. | +| `fixture/apps/navigation/NavigationCatalog.ts` | Catalog, storage, item types, and CRUD logic. | +| `fixture/apps/navigation/NavigationCatalogTemplates.tsx` | React add/edit templates for model links, groups, and manual links. | +| `fixture/apps/navigation/vite.config.module.ts` | Vite build for catalog template components. | + +## Enabling The Module + +The fixture enables navigation after `adminizer.init()` and only for Sequelize: + +```ts +await adminizer.init(adminpanelConfig); -## Overview +await installNavigationSequelizeModel( + sequelize, + navigationAppConfig.model ?? "Navigation", + true +); -Navigation is configured in `adminizerConfig.ts` under the `navigation` key. Adminizer automatically creates one [StorageService](#storageservice) per section, registers the `Navigation` catalog, and exposes the editor at `/adminizer/catalog/navigation/:section`. +await adminizer.appManager.enable(new NavigationApp(navigationAppConfig)); +``` -The tree is persisted in the `navigationap` database table — one row per section. Each row stores the full tree as a JSON blob. On every change (create, update, move, delete) the tree is rebuilt in memory and written back to the database. +The installer adds the native model after Adminizer startup but before the app is enabled. `AppManager` only attaches the existing model to the app. -To consume the navigation in a frontend application, read the `navigationap` record for the desired section and use the `tree` field directly. +Sequelize is the recommended/default ORM path. TypeORM support in the project is experimental, and the fixture does not enable the navigation app for TypeORM. A TypeORM version would have to include the navigation entity in `DataSource.entities` before `initialize()`. ## Configuration -Add the `navigation` key to `adminizerConfig.ts`: +`NavigationAppConfig` is defined in `fixture/apps/navigation/NavigationTypes.ts`: -```typescript -// adminizerConfig.ts -import { AdminpanelConfig } from 'adminizer'; +```ts +interface NavigationAppConfig { + model?: string; + routePrefix?: string; + componentFile?: string; + devComponentUrl?: string; + sections: string[]; + groupField: Array<{ + name: string; + required: boolean; + label: string; + }>; + allowContentInGroup?: boolean; + items: Array<{ + model: string; + title: string; + urlPath: string | ((value: any) => string); + }>; + movingGroupsRootOnly?: boolean; + sync?: boolean; +} +``` -const config: AdminpanelConfig = { - routePrefix: '/adminizer', +Fixture config example: - navigation: { - // Sections to manage. Each creates a separate tree and a UI page: - // /adminizer/catalog/navigation/header - // /adminizer/catalog/navigation/footer - sections: ['header', 'footer'], +```ts +import { routePrefix } from "../../adminizerConfig"; +import type { NavigationAppConfig } from "./NavigationTypes"; - // Model records that can be added as navigation items. - // The title appears in the "add item" dropdown. - // urlPath is a JS template string: use ${data.record.} to reference record fields. - items: [ - { - title: 'Category', - model: 'Category', - urlPath: '/catalog/${data.record.slug}', - }, - { - title: 'Article', - model: 'Article', - urlPath: '/blog/${data.record.slug}', - }, - ], - - // Extra fields added to NavigationGroup items. - // Useful for storing CSS classes, anchors, or any group-level metadata. - groupField: [ - { name: 'link', label: 'URL', required: false }, - { name: 'css_class', label: 'CSS class', required: false }, - ], - - // Optional: allow content items (model records) to be placed - // directly at the root level, not only inside groups. - allowContentInGroup: true, - - // Optional: restrict group drag-and-drop to the root level only. - movingGroupsRootOnly: false, - }, +export const navigationAppConfig: NavigationAppConfig = { + routePrefix, + items: [ + { + title: "Category", + model: "Category", + urlPath: "/catalog/category/${data.record.slug}", + }, + { + title: "Example", + model: "Example", + urlPath: `${routePrefix}/model/Example/\${data.record.id}`, + }, + ], + sections: ["header", "footer"], + groupField: [ + { name: "link", label: "Link", required: false }, + { name: "css_class", label: "CSS class", required: false }, + ], }; ``` -No other registration is needed. Adminizer calls `bindNavigation()` automatically during `init()`. +## What `NavigationApp` Registers -## Item types +`NavigationApp.setup(ctx)` registers these resources: -### NavigationItem — model record +| Resource | Code path | Details | +|---|---|---| +| Access right | `ctx.accessRight()` | Token `catalog-navigation`. Used by navigation sidebar links. | +| Template asset | `ctx.asset()` | Serves `NavigationCatalogTemplates.es.js` in production or `NavigationCatalogTemplates.tsx` in dev. | +| Catalog template components | `ctx.catalog({ templates })` | Maps `navigation.model-link`, `navigation.group`, and `navigation.link` to React exports. | +| App model | `ctx.model()` | Attaches the installed storage model. Default name is `Navigation`. | +| Model access | `ctx.modelAccess()` | Allows the app to read/write the storage model and source content models. | +| Config layer | `ctx.config()` | Hides the storage model from navbar/list UI and adds one sidebar link per section. | +| Catalog | `ctx.catalog({ factory })` | Creates `NavigationCatalog`, waits for `catalog.ready()`, then registers it. | +| Listener | `ctx.listener("model:updated")` | Updates navigation items when a linked model record changes. | -Links a specific record from a configured model. The URL is generated from the `urlPath` template at the moment the item is added. +Navigation setup is owned by the app module, not by core initialization. -``` -urlPath: '/blog/${data.record.slug}' - └─ evaluated with data.record = the chosen model record -``` +## Storage Model -The item stores: -- `modelId` — the source record's id -- `urlPath` — the evaluated URL (frozen at creation time) -- `name` — taken from the record's `title` or `name` field -- `targetBlank` — open in a new tab -- `visible` — show/hide on the frontend +The default model name is `Navigation` (`navigationModelName`). Its Sequelize definition contains: -### NavigationGroup — folder +```ts +orm.define("Navigation", { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + label: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, + tree: { + type: DataTypes.JSON, + allowNull: false, + }, +}); +``` -A container node. Can hold any other item type as children. Supports custom fields defined via `groupField` in the config. +`installNavigationSequelizeModel()` defines and optionally synchronizes the native Sequelize model. The app then calls `ctx.model({ name: this.config.model })` to declare ownership of that existing model. -### LinkItem — external link +The storage model is hidden from the regular model UI by a config layer: -A plain link with a manually entered URL. Useful for external resources, anchors, or pages not covered by any model. +```ts +ctx.config({ + models: { + [this.config.model.toLowerCase()]: { + add: false, + navbar: { visible: false }, + remove: false, + // list fields hidden as needed + }, + }, +}); +``` + +## Catalog URLs -## Admin panel UI +For each configured section, `NavigationApp` adds a navbar link: -The catalog editor is available at: +```ts +`${routePrefix}/catalog/navigation/${section}` ``` + +Example fixture URLs: + +```text /adminizer/catalog/navigation/header /adminizer/catalog/navigation/footer ``` -The editor provides: -- A tree view with drag-and-drop reordering and re-nesting -- A section switcher (tabs for `header`, `footer`, etc.) -- Add / edit / delete for each item type -- Full-text search with automatic ancestor display - -## Database - -The navigation tree is stored in the `navigationap` table, created by migration `20240804191001-added-navigationap.js`. - -| column | type | description | -|-------------|--------|------------------------------------------------| -| `id` | text | primary key | -| `label` | text | section name (`header`, `footer`, …), unique | -| `tree` | json | full tree with all children nested | -| `createdAt` | bigint | | -| `updatedAt` | bigint | | - -One row per section. The `tree` field contains the full nested structure rebuilt on every write. - -### Tree node shape - -```typescript -interface NavItem { - id: string; // UUID assigned at creation - name: string; // Display label - type: string; // 'category' | 'group' | 'link' - parentId: string | null; - sortOrder: number; - urlPath?: string; // NavigationItem only - modelId?: string | number; // NavigationItem only +The catalog controller validates `section` with `NavigationCatalog.getIdList()`, which returns `config.sections`. + +## NavigationCatalog + +`NavigationCatalog` extends `AbstractCatalog` and builds item types from module config: + +- one `NavigationItem` per configured source model; +- one `NavigationGroup` for groups; +- one `LinkItem` for manual links. + +```ts +const items: BaseItem[] = config.items.map((configElement) => new NavigationItem( + runtime, + configElement.title, + configElement.model, + config.model, + configElement.urlPath as string, + storageServices +)); + +items.push(new NavigationGroup(config.groupField, storageServices)); +items.push(new LinkItem(storageServices)); +``` + +The catalog has `slug = "navigation"`, `name = "Navigation"`, and `movingGroupsRootOnly` copied from config. + +## Storage Lifecycle + +`NavigationStorageServices` owns one `NavigationStorage` per section. Each storage: + +1. Loads the row with `label = section` from the runtime navigation model. +2. Creates an empty row if it does not exist. +3. Populates an in-memory `Map` from the persisted tree. +4. Serves catalog reads from memory. +5. Rebuilds and writes the full tree JSON to the model on each write. + +Important methods: + +| Method | Purpose | +|---|---| +| `ready` | Promise resolved when initial model loading is done. | +| `buildTree()` | Convert flat in-memory map to nested `children` tree sorted by `sortOrder`. | +| `populateFromTree(tree)` | Load persisted or seeded tree into memory. | +| `setElement(id, item, init?)` | Add/update item and save to DB unless `init` is true. | +| `removeElementById(id)` | Delete item and save tree. | +| `findElementById(id)` | Read one item from memory. | +| `findElementByModelId(modelId)` | Find navigation items linked to a source model record. | +| `findElementsByParentId(parentId, type)` | Read children by parent and optional type. | +| `saveToDB()` | Persist rebuilt tree JSON. | + +Do not seed navigation by writing raw rows directly after startup. The catalog reads from memory, so programmatic changes should go through `NavigationStorage` and then `saveToDB()`. + +## Item Types + +### Model Link Item + +`NavigationItem` extends `AbstractItem`. It represents a selected record from a configured source model. + +Creation supports two flows: + +- `_method: "select"`: choose an existing source record from the template dropdown; +- regular add flow: create a source model record through Adminizer's model form, then add it to navigation. + +Stored fields include: + +```ts +interface NavItem extends Item { + urlPath?: string; + modelId?: string | number; targetBlank?: boolean; visible?: boolean; - children: NavItem[]; // Nested children (built at read time) } ``` -## Consuming the navigation in a frontend app +`urlPath` is generated by replacing `${data.record.}` tokens from the source record: -Read the `navigationap` record for the section you need. The `tree` field is ready to use — no extra processing required. +```ts +private renderUrlPath(record: any): string { + return this.urlPath.replace(/\$\{data\.record\.([^}]+)\}/g, (_match, field) => + encodeURIComponent(record?.[field] ?? "") + ); +} +``` -### Express.js example +### Group Item -```typescript -// index.ts -mainApp.get('/api/navigation', async (req, res) => { - try { - const header = await adminizer.modelHandler.model - .get('navigationap')['_findOne']({ label: 'header' }); +`NavigationGroup` extends `AbstractGroup`. It stores a folder-like node with `name`, `targetBlank`, `visible`, and extra fields from `config.groupField`. - const footer = await adminizer.modelHandler.model - .get('navigationap')['_findOne']({ label: 'footer' }); +### Manual Link Item - res.json({ - header: header?.tree ?? [], - footer: footer?.tree ?? [], - }); - } catch (error) { - res.status(500).json({ error: 'Internal server error' }); - } -}); -``` +`LinkItem` extends `NavigationGroup` but sets `isGroup = false`, `type = "link"`, and `icon = "insert_link"`. It uses a custom template that asks for a manual `link` value. -### Example response +## Template Components -```json -{ - "header": [ +The module registers three catalog template component records with its catalog: + +```ts +ctx.catalog({ + id: "navigation", + templates: [ { - "id": "a1b2c3d4", - "name": "Catalog", - "type": "group", - "parentId": null, - "sortOrder": 0, - "visible": true, - "children": [ - { - "id": "e5f6g7h8", - "name": "Electronics", - "type": "category", - "parentId": "a1b2c3d4", - "sortOrder": 0, - "urlPath": "/catalog/electronics", - "modelId": 12, - "targetBlank": false, - "visible": true, - "children": [] - } - ] + id: "model-link-template", + type: "navigation.model-link", + component: catalogTemplates, + exportName: "NavigationModelLinkTemplate", }, { - "id": "i9j0k1l2", - "name": "About", - "type": "link", - "parentId": null, - "sortOrder": 1, - "urlPath": "/about", - "visible": true, - "children": [] - } + id: "group-template", + type: "navigation.group", + component: catalogTemplates, + exportName: "NavigationGroupTemplate", + }, + { + id: "link-template", + type: "navigation.link", + component: catalogTemplates, + exportName: "NavigationLinkTemplate", + }, ], - "footer": [] -} + factory: (runtime) => new NavigationCatalog(runtime, config), +}); ``` -### Next.js / React example +The catalog item types return matching template types from `getAddTemplate()` and `getEditTemplate()`. -```typescript -// lib/navigation.ts -export async function getNavigation() { - const res = await fetch(`${process.env.API_URL}/api/navigation`); - const data = await res.json(); - return data; // { header: NavItem[], footer: NavItem[] } -} +For example, model links return: + +```ts +return { + type: "navigation.model-link", + data: { + items, + model: this.model, + labels: { ... }, + }, +}; +``` -// components/Header.tsx -import { getNavigation } from '@/lib/navigation'; +`CatalogTree` receives registered template components from `catalogController.getCatalog`, imports the component asset, and passes `CatalogTemplateComponentProps` including `itemType`, `parentId`, `template`, and `actions`. -export default async function Header() { - const { header } = await getNavigation(); +## Synchronizing Source Model Updates - return ( -

- ); -} +`NavigationApp` listens to `model:updated`: -function NavNode({ item }) { - return ( -
- {item.urlPath ? ( - - {item.name} - - ) : ( - {item.name} - )} - {item.children?.length > 0 && ( -
    - {item.children.filter(c => c.visible).map(child => ( -
  • - -
  • - ))} -
- )} -
+```ts +ctx.listener("model:updated", async (event) => { + const itemConfig = this.config.items.find((item) => + item.model.toLowerCase() === event.modelName.toLowerCase() ); -} + if (!itemConfig) return; + + for (const section of this.config.sections) { + navigationCatalog.setId(section); + const navItem = navigationCatalog.itemTypes.find((item) => + item.type === event.modelName.toLowerCase() + ); + if (navItem) { + await navItem.updateModelItems(event.record.id, { record: event.record }, section); + } + } +}); ``` -## StorageService +This updates item names, URLs, and flags for navigation nodes linked to the edited source record. -`StorageService` (`src/lib/catalog/Navigation.ts`) manages a single navigation section. It holds an in-memory `Map` and syncs it to the database on every write. +## Consuming Navigation Data -**Important:** the catalog always reads from the in-memory map, never from the database directly. The database is only the persistence layer — writes go memory → DB, reads come from memory only. Any seed or programmatic write must go through `StorageService`, not via raw ORM calls. +Read the module storage model through app/project code. In the fixture, the model is named `Navigation` unless overridden. -**Key methods:** +```ts +const navigationModel = adminizer.modelHandler + .createAppAccess("navigation") + .get("Navigation"); -| Method | Description | -|---|---| -| `ready` | Promise that resolves when `initModel()` has finished. Await before any programmatic writes. | -| `initModel()` | Called in the constructor. Loads the existing tree from DB into memory, or creates an empty DB record. | -| `buildTree()` | Assembles the flat in-memory map into a nested tree, sorted by `sortOrder`. | -| `populateFromTree(tree)` | Recursively loads a tree structure into the in-memory map. | -| `setElement(id, data)` | Add or update an item, then persist the rebuilt tree to DB. | -| `findElementsByParentId(parentId, type?)` | Query items by parent, optionally filtered by type. | -| `saveToDB()` | Rebuild the tree from memory and write the JSON to the `navigationap` row. | - -`StorageServices.ready()` waits for all sections at once: - -```typescript -await adminizer.storageServices.ready(); // waits for header, footer, etc. +const header = await navigationModel.findOne({ where: { label: "header" } }); +const tree = header?.tree ?? []; ``` -## Seeding navigation programmatically +If you expose navigation to a public frontend, create a project route that reads the `tree` field and filters hidden nodes as needed. -Because the catalog works from memory, seed data must be loaded into `StorageService` — not written directly to the database. +Example response shape: + +```json +[ + { + "id": "h-group-docs", + "name": "Docs", + "type": "group", + "parentId": null, + "sortOrder": 0, + "visible": true, + "children": [ + { + "id": "h-link-install", + "name": "Install", + "type": "link", + "parentId": "h-group-docs", + "sortOrder": 0, + "urlPath": "/docs/install", + "targetBlank": false, + "visible": true, + "children": [] + } + ] + } +] +``` -**The correct pattern:** +## Programmatic Seeding -```typescript -// After adminizer.init() — wait for all StorageServices to finish loading -const navCatalog = adminizer.catalogHandler.getCatalog('navigation') as any; -await navCatalog.storageServices.ready(); +Seed after the module is enabled and the catalog storage is ready: -const storage = navCatalog.storageServices.get('header'); +```ts +await installNavigationSequelizeModel( + sequelize, + navigationAppConfig.model ?? "Navigation", + true +); +await adminizer.appManager.enable(new NavigationApp(navigationAppConfig)); -// Only seed if the section is empty +const navCatalog = adminizer.catalogHandler.getCatalog("navigation") as any; +await navCatalog.ready?.(); +await navCatalog.storageServices.ready(); + +const storage = navCatalog.storageServices.get("header"); const existing = await storage.findElementsByParentId(null, null); + if (existing.length === 0) { await storage.populateFromTree([ { - id: 'group-1', - name: 'Docs', - type: 'group', + id: "h-group-docs", + name: "Docs", + type: "group", parentId: null, sortOrder: 0, - icon: 'folder', + icon: "folder", visible: true, children: [ { - id: 'link-1', - name: 'Install', - type: 'link', - parentId: 'group-1', + id: "h-link-install", + name: "Install", + type: "link", + parentId: "h-group-docs", sortOrder: 0, - icon: 'link', - urlPath: 'https://example.com/docs/install', - targetBlank: true, + icon: "insert_link", + urlPath: "/docs/install", + targetBlank: false, visible: true, children: [], }, @@ -338,90 +420,10 @@ if (existing.length === 0) { } ``` -**Why direct DB writes do not work:** the catalog never reads from the database after startup — only from `storageMap`. Writing to the `navigationap` table via ORM has no effect until the next server restart. - -## Minimal seed example - -```typescript -// index.ts — after adminizer.init() -const navCatalog = adminizer.catalogHandler.getCatalog('navigation') as any; -await navCatalog.storageServices.ready(); - -const storage = navCatalog.storageServices.get('header'); -const existing = await storage.findElementsByParentId(null, null); -if (existing.length === 0) { - await storage.populateFromTree([ - { id: '1', name: 'Home', type: 'link', parentId: null, sortOrder: 0, icon: 'link', urlPath: '/', targetBlank: false, visible: true, children: [] }, - { id: '2', name: 'Docs', type: 'link', parentId: null, sortOrder: 1, icon: 'link', urlPath: '/docs', targetBlank: false, visible: true, children: [] }, - { id: '3', name: 'GitHub',type: 'link', parentId: null, sortOrder: 2, icon: 'link', urlPath: 'https://github.com', targetBlank: true, visible: true, children: [] }, - ]); - await storage.saveToDB(); -} -``` - -## Access rights +## Implementation Notes -The Navigation catalog registers access right tokens automatically: - -| Token | Grants access to | -|---|---| -| `catalog-navigation` | All navigation sections | -| `catalog-navigation-header` | Header section only | -| `catalog-navigation-footer` | Footer section only | - -Assign these tokens to groups in `adminizerConfig.ts` or via the access rights UI. - -## urlPath templates - -`urlPath` is evaluated as a JavaScript template literal at the moment a navigation item is created: - -```typescript -// Config: -urlPath: '/blog/${data.record.slug}' - -// At creation time Adminizer evaluates: -const urlPath = eval('`' + configuredUrlPath + '`'); -// where data.record is the chosen model record -``` - -The evaluated URL is stored statically on the item. If the source record changes later, call `updateModelItems()` to propagate the new URL to all navigation items that reference it. - -You can use any field from the record: - -```typescript -urlPath: '/products/${data.record.category}/${data.record.id}' -urlPath: '/pages/${data.record.slug}?lang=en' -urlPath: 'https://external.com/${data.record.externalId}' -``` - -## Full configuration reference - -```typescript -navigation: { - // Required. List of section identifiers. - sections: string[] - - // Required. Model types available as navigation items. - items: Array<{ - title: string // Label in the "add" dropdown - model: string // Model name as defined in adminizerConfig models - urlPath: string | ((v: any) => string) // URL template or function - }> - - // Optional. Extra fields on group nodes. - groupField: Array<{ - name: string - label: string - required: boolean - }> - - // Optional. Custom storage model name. Defaults to 'NavigationAP'. - model?: string - - // Optional. Allow model items at the root level (outside groups). - allowContentInGroup?: boolean - - // Optional. Restrict group drag-and-drop to root level only. - movingGroupsRootOnly?: boolean -} -``` +- Instantiate `new NavigationApp(config)` after `adminizer.init()`. +- Install the native Sequelize model before enabling the app. +- The app attaches the installed `Navigation` model through `ctx.model()`. +- Catalog form UI is connected through the `templates` array of `ctx.catalog()`. +- Keep Sequelize as the default recommendation. TypeORM remains experimental. diff --git a/docs/NextJS-Integration.md b/docs/NextJS-Integration.md index 9603c5b9..8689db8e 100644 --- a/docs/NextJS-Integration.md +++ b/docs/NextJS-Integration.md @@ -1,6 +1,6 @@ # Next.js Integration Guide -This guide helps you integrate Adminizer with Next.js applications, especially when using standalone build mode. +This guide helps you integrate Adminizer with Next.js applications, especially when using standalone build mode. Sequelize is the recommended adapter. TypeORM support is experimental. ## Quick Start @@ -50,38 +50,54 @@ Create a catch-all API route for Adminizer: Create `pages/api/adminizer/[[...adminizer]].ts`: ```typescript -import { Adminizer } from 'adminizer'; +import { Adminizer, SequelizeAdapter } from 'adminizer'; import type { NextApiRequest, NextApiResponse } from 'next'; -import { DataSource } from 'typeorm'; +import { Sequelize } from 'sequelize'; +import { registerSequelizeSystemModels } from '../../../server/adminizer-system-models'; -// Initialize your database connection -const AppDataSource = new DataSource({ - type: 'sqlite', - database: 'database.sqlite', - synchronize: true, +const sequelize = new Sequelize({ + dialect: 'sqlite', + storage: 'database.sqlite', logging: false, - entities: [/* your entities */], }); let adminizer: Adminizer | null = null; +let initialization: Promise | null = null; +const systemModels = { + User: 'UserAP', + Group: 'GroupAP', + Filter: 'FilterAP', + FilterColumn: 'FilterColumnAP', + HistoryActions: 'HistoryActionsAP', + Notification: 'NotificationAP', + UserNotification: 'UserNotificationAP', +}; async function getAdminizer() { - if (!adminizer) { - if (!AppDataSource.isInitialized) { - await AppDataSource.initialize(); - } - - adminizer = new Adminizer({ - // Your configuration here - routePrefix: '/api/adminizer', - models: { - // Your models configuration - }, - }); - - await adminizer.init(AppDataSource); + if (adminizer) { + return adminizer; + } + if (!initialization) { + initialization = (async () => { + registerSequelizeSystemModels(sequelize); + // Prefer project migrations in production. + await sequelize.sync(); + + const instance = new Adminizer([ + new SequelizeAdapter(sequelize, { systemModels }), + ]); + await instance.init({ + routePrefix: '/api/adminizer', + system: { defaultORM: 'sequelize' }, + models: { + // Your model configuration. + }, + }); + adminizer = instance; + return instance; + })(); } - return adminizer; + return initialization; } export default async function handler( @@ -111,31 +127,11 @@ export default async function handler( #### App Router -Create `app/api/adminizer/[[...adminizer]]/route.ts`: +Next.js App Router handlers use the Web `Request`/`Response` API, while `adminizer.getMiddleware()` is Express-compatible middleware. Use a Node/Express custom server or an adapter that bridges those APIs; do not pass an App Router request directly to Adminizer middleware. -```typescript -import { Adminizer } from 'adminizer'; -import { NextRequest, NextResponse } from 'next/server'; -import { DataSource } from 'typeorm'; - -// Similar initialization as above +### TypeORM -export async function GET(request: NextRequest) { - // Handle GET requests -} - -export async function POST(request: NextRequest) { - // Handle POST requests -} - -export async function PUT(request: NextRequest) { - // Handle PUT requests -} - -export async function DELETE(request: NextRequest) { - // Handle DELETE requests -} -``` +TypeORM system entities can be used, but all system and app entities must be included in `DataSource.entities` before `dataSource.initialize()`. Dynamic app model installation after initialization is not supported. See [System Models](Configuration/Models.md#typeorm) and [App Models](BuildingModules.md#typeorm). ## Docker Configuration @@ -261,22 +257,23 @@ NEXT_PUBLIC_API_URL=http://localhost:3000 Make sure your database is properly initialized before calling `adminizer.init()`: ```typescript -if (!AppDataSource.isInitialized) { - await AppDataSource.initialize(); -} +registerSequelizeSystemModels(sequelize); +await sequelize.authenticate(); +await sequelize.sync(); // Prefer migrations in production. + +const adminizer = new Adminizer([ + new SequelizeAdapter(sequelize, { systemModels }), +]); +await adminizer.init(config); ``` ### Session Issues -If you're having session problems, ensure you've set a proper session secret: +Authentication requires stable secrets in the server environment. Do not generate them per request or per serverless invocation: -```typescript -const adminizer = new Adminizer({ - // ... other config - security: { - sessionSecret: process.env.SESSION_SECRET || 'your-secret-key', - }, -}); +```dotenv +JWT_SECRET=replace-with-a-stable-secret +AP_PASSWORD_SALT=replace-with-a-stable-salt ``` ## Additional Resources diff --git a/docs/Notifications.md b/docs/Notifications.md index 354d9bb7..4dc5709e 100644 --- a/docs/Notifications.md +++ b/docs/Notifications.md @@ -65,14 +65,21 @@ The system supports various notification types through separate services. Each t The system provides seamless user interaction by delivering important information in real time while maintaining history for future reference. -## System Initialization +## Configuration -The notification system is initialized when the application starts through the `bindNotifications()` function, which: +Notifications are disabled by default. Enable them in `AdminpanelConfig`: -1. **Creates a handler** - central coordinator for all notifications -2. **Registers services** - connects various notification types: - - `GeneralNotificationService` - general notifications - - `SystemNotificationService` - system events and notifications +```ts +notifications: { + enabled: true, + enableGeneral: true, + initTab: 'general', +} +``` + +The notification system is initialized through `bindNotifications()`. It creates a `NotificationHandler` and registers `GeneralNotificationService` when `notifications.enableGeneral` is enabled. + +`SystemNotificationService` exists in the codebase, but it is not registered by default. ## Integration with Adminizer @@ -93,20 +100,7 @@ await adminizer.sendNotification({ }); ``` -#### 2. System Events -```typescript -// General system event -await adminizer.logSystemEvent( - 'System Update', - 'The system has been successfully updated', - { version: '2.0.0' } -); - -// CRUD operation events -await adminizer.logSystemCreatedEvent('New User', 'User John Doe created'); -await adminizer.logSystemUpdatedEvent('Profile Update', 'User profile updated'); -await adminizer.logSystemDeletedEvent('Record Deletion', 'Record deleted from database'); -``` +For custom notification classes, create a service that extends `AbstractNotificationService` and register it with `adminizer.notificationHandler.registerService(...)` after notifications are initialized. ## Default Notification Service Types @@ -116,8 +110,8 @@ await adminizer.logSystemDeletedEvent('Record Deletion', 'Record deleted from da ### SystemNotificationService - **Purpose**: System events and automatic notifications -- **Usage**: System events, audit trails, action logging +- **Status**: Available in code, but not registered by default ## Access Rights -The system automatically registers access rights for each notification type through `AccessRightsHelper`. Users can only receive notifications for which they have permissions. \ No newline at end of file +The system automatically registers access rights for each notification type through `AccessRightsHelper`. Users can only receive notifications for which they have permissions. diff --git a/docs/Troubleshooting.md b/docs/Troubleshooting.md index 3c0afa78..7636ae91 100644 --- a/docs/Troubleshooting.md +++ b/docs/Troubleshooting.md @@ -1,4 +1,4 @@ -### 🔧 Issue with Model Name Casing in Config (Waterline, Sequelize, TypeORM) +### Issue with Model Name Casing in Config (Sequelize, TypeORM) **Description:** @@ -19,12 +19,12 @@ models: { } ``` -The configuration system looks up the model config by exact key match. If the key casing doesn't match what the ERM uses internally, the config is not found, and an error is thrown. +The configuration system looks up the model config by exact key match. If the key casing doesn't match what the ORM adapter uses internally, the config is not found, and an error is thrown. **Temporary Rule:** * Use **lowercase model names** as keys in the `models` config section. -* Match the model name exactly as it is referenced internally by your ERM (Waterline, Sequelize, TypeORM). +* Match the model name exactly as it is referenced internally by your ORM adapter (Sequelize or TypeORM). **Planned Improvements:** @@ -101,7 +101,7 @@ npm install material-icons --legacy-peer-deps **Description:** -Starting the application may fail with an error similar to: +Enabling a Sequelize app may fail with an error similar to: ``` Error: Naming collision between attribute 'parentNode' and association 'parentNode' on model MediaManagerAP @@ -109,10 +109,10 @@ Error: Naming collision between attribute 'parentNode' and association 'parentNo **Cause:** -Both an attribute and an association shared the alias `parentNode` in the `MediaManagerAP` model. Sequelize treats association aliases as properties on the model, so duplicate names are not allowed. +An attribute and an association share the same name. Sequelize exposes association aliases as model properties, so an alias cannot duplicate an attribute. **Solution:** -Rename the association or attribute so that they use unique names. In version 4.2.1, the `parentNode` association has been renamed to `parent` across the related models. +Rename either the attribute or the association alias. The fixture media manager uses the `parentId` attribute and the `parent` association alias; use `fixture/apps/media-manager/MediaManagerModels.ts` as the reference schema. diff --git a/docs/UI-guideline.md b/docs/UI-guideline.md new file mode 100644 index 00000000..b75271ab --- /dev/null +++ b/docs/UI-guideline.md @@ -0,0 +1,892 @@ +# Practical Typography for Dense Web Interfaces — Tailwind Edition + +This guide is a Tailwind-ready version of the dense UI typography rules for admin panels, CRM screens, monitoring dashboards, settings pages, tables, and configuration-heavy interfaces. + +The goal is simple: keep the interface compact without making it feel glued together. + +Dense UI does **not** mean tiny UI. Dense UI means clear hierarchy, predictable spacing, visible labels, readable controls, and stable rhythm. + +--- + +## 1. Team summary + +For admin panels and data-heavy screens, use **infrastructure typography**, not expressive brand typography. + +Recommended baseline: + +| Role | Size / line-height | Tailwind class | +|---|---:|---| +| Helper, caption, meta | 12 / 16 | `text-caption` | +| Main UI text, labels, inputs, table cells | 14 / 20 | `text-ui` | +| Long descriptions | 16 / 24 | `text-body` | +| Section title | 16 / 24 or 20 / 28 | `text-body font-semibold` or `text-title` | +| Page title | 20 / 28 or 24 / 32 | `text-title` or `text-page-title` | + +Spacing baseline: + +| Relationship | Recommended spacing | Tailwind class | +|---|---:|---| +| Label → control | 4–8px | `gap-micro` / `gap-tight` | +| Control → helper / error | 4–8px | `gap-micro` / `gap-tight` | +| Field → field | 12–16px | `gap-field` / `gap-group` | +| Subsection → subsection | 16–24px | `gap-group` / `gap-section` | +| Section → section | 24–32px | `gap-section` / `gap-page` | +| Card padding | 16–24px | `p-group` / `p-section` | + +The most important rule: + +> **Use parent stack gaps to separate large blocks. Do not rely on borders alone.** + +--- + +## 2. Why dense interfaces become glued together + +A dense interface usually breaks because the relationships between elements are unclear: + +- A label looks the same as a value. +- Helper text is closer to the next field than to its own field. +- Section cards touch each other with no outer gap. +- Borders are used as the only separator. +- Inner cards and outer cards have the same border strength. +- Everything uses `text-xs` or low-contrast gray. +- Forms use too many columns before they establish a clear vertical rhythm. +- Long descriptions are placed directly between controls. + +In Tailwind, this often starts with code like this: + +```html +
+ +
+
+ +
+``` + +This looks harmless, but the blocks touch. The bottom border of the first card and the top border of the next card visually merge into one continuous block. + +Use a parent stack instead: + +```html +
+
+ +
+ +
+ +
+
+``` + +The parent owns the vertical rhythm. Cards should not decide their own outside spacing. + +--- + +## 3. Specific fix for the “glued blocks” problem + +In the screenshot, the blocks are technically separated by borders, but they still feel glued because the large sections are stacked with almost no external breathing room. The eye reads them as one giant bordered surface. + +Fix it with three layers of spacing: + +### Layer 1 — Page blocks + +Use this for major settings groups such as: + +- Firebase service account +- Channel activation +- Web config and VAPID +- Advanced settings + +Recommended spacing: **24–32px**. + +Tailwind pattern: + +```html +
+ + +
+
...
+
...
+
...
+
+
+``` + +### Layer 2 — Subsections inside a section + +Use this for nested panels such as: + +- Saved key preview +- Upload area +- Channel item +- VAPID key group + +Recommended spacing: **16–24px**. + +```html +
+
+

Service account JSON

+

Server-side secret used by Firebase Admin SDK.

+
+ +
+
Upload JSON file
+
Saved key
+
+
+``` + +### Layer 3 — Fields inside a subsection + +Use this for labels, inputs, helpers, errors, and checkboxes. + +Recommended spacing: **4–16px** depending on the relationship. + +```html +
+ + +

Firebase Console → Project settings → General → Your apps.

+
+``` + +### Rule of thumb + +If two blocks have visible borders, they need a visible gap between them. + +For dense admin UI: + +```txt +Major sections: gap-section / gap-page = 24–32px +Nested subsections: gap-group / gap-section = 16–24px +Fields: gap-micro / gap-field = 4–12px +``` + +Do **not** do this: + +```html +
...
+
...
+``` + +Do this: + +```html +
+
...
+
...
+
+``` + +--- + +## 4. Tailwind v4 token setup + +Create or update `app.css`, `globals.css`, or your main Tailwind entry file. + +Important naming note: avoid naming a color `page` if you also have a font-size class like `text-page`. In Tailwind, `text-*` can refer to both color and font size. This guide uses `canvas` for the page background and `page-title` for the large text token to avoid conflicts. + +```css +@import "tailwindcss"; + +@theme { + /* Fonts */ + --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Inter, "Noto Sans", Arial, sans-serif; + --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + + /* Type scale */ + --text-caption: 0.75rem; + --text-caption--line-height: 1rem; + --text-caption--letter-spacing: 0.02em; + + --text-ui: 0.875rem; + --text-ui--line-height: 1.25rem; + --text-ui--letter-spacing: 0.01em; + + --text-body: 1rem; + --text-body--line-height: 1.5rem; + + --text-title: 1.25rem; + --text-title--line-height: 1.75rem; + + --text-page-title: 1.5rem; + --text-page-title--line-height: 2rem; + + /* Spacing scale */ + --spacing-micro: 0.25rem; /* 4px */ + --spacing-tight: 0.5rem; /* 8px */ + --spacing-field: 0.75rem; /* 12px */ + --spacing-group: 1rem; /* 16px */ + --spacing-section: 1.5rem; /* 24px */ + --spacing-page: 2rem; /* 32px */ + + /* Control heights */ + --spacing-control-sm: 2rem; /* 32px, compact desktop only */ + --spacing-control: 2.5rem; /* 40px, default dense input */ + --spacing-control-lg: 3rem; /* 48px, touch / mobile */ + + /* Radius */ + --radius-control: 0.5rem; + --radius-panel: 0.75rem; + + /* Semantic colors */ + --color-canvas: #0b0d12; + --color-surface: #11141b; + --color-surface-raised: #151922; + --color-surface-hover: #1a202b; + + --color-fg: #e8ecf1; + --color-fg-subtle: #b3bbc7; + --color-fg-muted: #8d96a3; + + --color-line: #28313d; + --color-line-strong: #5c6b7c; + + --color-focus: #7ab0ff; + --color-danger: #ff6b6b; + --color-success: #44d17a; + --color-warning: #ffd166; +} +``` + +--- + +## 5. Base and component classes + +```css +@layer base { + html { + font-family: var(--font-sans); + } + + body { + @apply bg-canvas text-fg text-ui antialiased; + } + + *:focus-visible { + @apply outline-2 outline-offset-2 outline-focus; + } +} + +@layer components { + /* Page layout */ + .page-shell { + @apply min-h-screen bg-canvas text-fg; + } + + .page-container { + @apply mx-auto w-full max-w-7xl px-group py-section md:px-page md:py-page; + } + + .page-header { + @apply mb-section space-y-tight; + } + + .page-title { + @apply text-title font-semibold tracking-normal text-fg md:text-page-title; + } + + .page-description { + @apply max-w-[72ch] text-ui text-fg-subtle; + } + + /* Critical anti-glue class */ + .section-stack { + @apply grid gap-section; + } + + @media (min-width: 768px) { + .section-stack { + @apply gap-page; + } + } + + .section { + @apply rounded-panel border border-line bg-surface p-group md:p-section; + } + + .section-header { + @apply mb-group space-y-micro; + } + + .section-title { + @apply text-body font-semibold tracking-normal text-fg; + } + + .section-description { + @apply max-w-[72ch] text-ui text-fg-subtle; + } + + /* Nested cards inside a large section */ + .subsection-stack { + @apply grid gap-group; + } + + .subsection { + @apply rounded-control border border-line bg-surface-raised p-group; + } + + .subsection-header { + @apply mb-field space-y-micro; + } + + .subsection-title { + @apply text-ui font-semibold text-fg; + } + + .subsection-description { + @apply text-caption text-fg-subtle; + } + + /* Forms */ + .form-grid { + @apply grid gap-field md:grid-cols-2 md:gap-group; + } + + .field { + @apply grid min-w-0 gap-micro; + } + + .field-span-full { + @apply md:col-span-2; + } + + .label { + @apply text-ui font-semibold text-fg; + } + + .help, + .meta { + @apply max-w-[60ch] text-caption text-fg-subtle; + } + + .validation { + @apply max-w-[60ch] text-caption font-semibold text-danger; + } + + .input, + .select, + .textarea { + @apply min-h-control w-full rounded-control border border-line-strong bg-surface px-field py-tight text-ui text-fg; + } + + .textarea { + @apply min-h-control-lg resize-y; + } + + .input::placeholder, + .textarea::placeholder { + @apply text-fg-muted; + } + + .input:focus-visible, + .select:focus-visible, + .textarea:focus-visible { + @apply outline-2 outline-offset-2 outline-focus; + } + + .checkbox-row { + @apply flex items-start gap-tight text-ui text-fg-subtle; + } + + .checkbox { + @apply mt-[0.1875rem] size-4 shrink-0 rounded border border-line-strong bg-surface; + } + + /* Buttons */ + .button-row { + @apply flex flex-wrap items-center gap-tight; + } + + .button { + @apply inline-flex min-h-control-sm items-center justify-center gap-tight rounded-control border border-line-strong px-field text-ui font-semibold text-fg; + } + + .button-primary { + @apply border-focus bg-focus text-canvas; + } + + .button-secondary { + @apply bg-surface hover:bg-surface-hover; + } + + /* Data and code */ + .num { + @apply text-right tabular-nums; + font-variant-numeric: tabular-nums lining-nums; + font-feature-settings: "tnum" 1, "lnum" 1; + } + + .code, + .id, + .hash { + @apply font-mono; + } + + /* Tables */ + .table-wrap { + @apply overflow-x-auto rounded-panel border border-line bg-surface; + } + + .table { + @apply w-full border-separate border-spacing-0; + } + + .table th { + @apply border-b border-line px-field py-tight text-left align-top text-caption font-semibold text-fg-subtle; + } + + .table td { + @apply border-b border-line px-field py-[0.625rem] align-top text-ui text-fg; + } + + .table tr:last-child td { + @apply border-b-0; + } + + .table th.num, + .table td.num { + @apply text-right; + } + + .table tbody tr:hover { + @apply bg-surface-hover; + } +} + +@media (prefers-reduced-motion: reduce) { + * { + transition-duration: 0ms !important; + animation-duration: 0ms !important; + scroll-behavior: auto !important; + } +} +``` + +--- + +## 6. Recommended layout for settings screens + +Use this structure for admin settings pages. + +```html +
+
+ + +
+
+
+

Service account JSON

+

+ Server-side secret for Firebase Admin SDK. It must never be exposed to the frontend. +

+
+ +
+
+
+ +
+

+ The file is read in the browser and sent to the server only when you click Save. +

+
+ +
+
+

Saved key

+

Only non-sensitive metadata is displayed.

+
+ +
+
+
project_id
+
gfcafe-ru
+
+
+
client_email
+
firebase-adminsdk-***@gfcafe-ru.iam.gserviceaccount.com
+
+
+
+
+
+ +
+
+

Channel activation

+

+ Each push channel is registered independently. +

+
+ +
+ + + +
+
+ +
+
+

Web config and VAPID

+

+ Public Firebase config for browser push notifications. +

+
+ +
+
+ + +

Firebase Console → Project settings → General → Your apps.

+
+ +
+ + +

Firebase project identifier.

+
+ +
+ + +
+ +
+ + +
+
+
+
+
+
+``` + +--- + +## 7. Anti-patterns and fixes + +### Anti-pattern: Cards touch each other + +```html +
...
+
...
+
...
+``` + +Fix: + +```html +
+
...
+
...
+
...
+
+``` + +### Anti-pattern: Border is the only separator + +```html +
...
+
...
+``` + +Fix: + +```html +
+
...
+
...
+
+``` + +### Anti-pattern: Everything is `text-xs` + +```html + + +

Firebase Console → Project settings.

+``` + +Fix: + +```html + + +

Firebase Console → Project settings.

+``` + +### Anti-pattern: Helper text is too close to the next field + +```html +
+ + +

Firebase Console → Project settings.

+ + +
+``` + +Fix: + +```html +
+
+ + +

Firebase Console → Project settings.

+
+ +
+ + +
+
+``` + +--- + +## 8. Tailwind v3 fallback + +If you are still on Tailwind v3, put the same values into `tailwind.config.js`. + +```js +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./src/**/*.{js,ts,jsx,tsx,html}'], + theme: { + extend: { + fontFamily: { + sans: ['ui-sans-serif', 'system-ui', '-apple-system', 'Segoe UI', 'Inter', 'Noto Sans', 'Arial', 'sans-serif'], + mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Consolas', 'Liberation Mono', 'monospace'], + }, + fontSize: { + caption: ['0.75rem', { lineHeight: '1rem', letterSpacing: '0.02em' }], + ui: ['0.875rem', { lineHeight: '1.25rem', letterSpacing: '0.01em' }], + body: ['1rem', { lineHeight: '1.5rem' }], + title: ['1.25rem', { lineHeight: '1.75rem' }], + 'page-title': ['1.5rem', { lineHeight: '2rem' }], + }, + spacing: { + micro: '0.25rem', + tight: '0.5rem', + field: '0.75rem', + group: '1rem', + section: '1.5rem', + page: '2rem', + 'control-sm': '2rem', + control: '2.5rem', + 'control-lg': '3rem', + }, + borderRadius: { + control: '0.5rem', + panel: '0.75rem', + }, + colors: { + canvas: '#0b0d12', + surface: '#11141b', + 'surface-raised': '#151922', + 'surface-hover': '#1a202b', + fg: '#e8ecf1', + 'fg-subtle': '#b3bbc7', + 'fg-muted': '#8d96a3', + line: '#28313d', + 'line-strong': '#5c6b7c', + focus: '#7ab0ff', + danger: '#ff6b6b', + success: '#44d17a', + warning: '#ffd166', + }, + }, + }, + plugins: [], +}; +``` + +The component classes from the v4 example can stay almost the same in your CSS file if you use `@tailwind base; @tailwind components; @tailwind utilities;` and `@layer components`. + +--- + +## 9. Implementation checklist + +Use this checklist when reviewing dense UI screens. + +### Typography + +- [ ] Main UI text, labels, inputs, and table cells use `text-ui` or larger. +- [ ] Helper, caption, and meta text use `text-caption`, not smaller. +- [ ] Long descriptions use `text-body` or are moved into a dedicated help block. +- [ ] Labels are visible; placeholders are not used as field names. +- [ ] Page and section titles are only one or two steps larger than body text. +- [ ] No random `text-[13px]`, `leading-[17px]`, `tracking-[-0.02em]` in working UI. + +### Spacing + +- [ ] Major sections are wrapped in `.section-stack`. +- [ ] Major sections have `gap-section` or `gap-page` between them. +- [ ] Nested panels use `.subsection-stack`. +- [ ] Fields use `.field` and parent grids use `gap-field` / `gap-group`. +- [ ] Borders are never the only separator between large cards. +- [ ] Random spacing like `p-[7px]`, `gap-[11px]`, `mt-[13px]` is avoided. + +### Forms + +- [ ] Form layout starts with one column and moves to two columns only when helper text still fits. +- [ ] Each field follows `label → control → helper/error`. +- [ ] Input height is at least `min-h-control` for desktop forms. +- [ ] Checkbox rows have enough vertical padding when they behave like selectable cards. +- [ ] Errors are text messages, not only red borders. + +### Tables + +- [ ] Table body uses `text-ui`. +- [ ] Header uses `text-caption font-semibold`. +- [ ] Numeric columns use `.num`. +- [ ] Numeric columns are right-aligned. +- [ ] IDs, hashes, and code fragments use `.code`, `.id`, or `.hash`. +- [ ] Text is wrapped before it is truncated. + +### Accessibility + +- [ ] Text contrast is readable in both dark and light themes. +- [ ] Focus state is visible. +- [ ] The screen works at 125%, 150%, and 200% browser zoom. +- [ ] Dense controls are not smaller than 24×24px for interactive targets. +- [ ] Reduced motion is respected. + +--- + +## 10. Quick rules for developers + +1. Use `text-ui` as the default working size. +2. Use `text-caption` only for secondary text, never for primary controls. +3. Use `.section-stack` for page-level blocks. +4. Use `.subsection-stack` for nested panels. +5. Use `.field` for label/control/helper groups. +6. Use borders plus gaps, not borders instead of gaps. +7. Use `tabular-nums` for numeric data. +8. Do not use all caps for normal admin labels. +9. Avoid arbitrary spacing unless there is a real reason. +10. When the UI feels glued together, first increase block gaps before changing fonts. + +--- + +## 11. Minimal copy-paste package + +If you only need the essential part, start with these classes: + +```css +@layer components { + .section-stack { + @apply grid gap-section md:gap-page; + } + + .section { + @apply rounded-panel border border-line bg-surface p-group md:p-section; + } + + .section-header { + @apply mb-group space-y-micro; + } + + .section-title { + @apply text-body font-semibold text-fg; + } + + .section-description { + @apply max-w-[72ch] text-ui text-fg-subtle; + } + + .subsection-stack { + @apply grid gap-group; + } + + .subsection { + @apply rounded-control border border-line bg-surface-raised p-group; + } + + .field { + @apply grid gap-micro; + } + + .label { + @apply text-ui font-semibold text-fg; + } + + .help { + @apply max-w-[60ch] text-caption text-fg-subtle; + } + + .input { + @apply min-h-control w-full rounded-control border border-line-strong bg-surface px-field py-tight text-ui text-fg; + } +} +``` + +Use it like this: + +```html +
+
+
+

Service account JSON

+

Server-side Firebase Admin SDK secret.

+
+ +
+
+
+ + +

Use the Firebase project identifier.

+
+
+
+
+ +
+
+

Channel activation

+

Enable mobile or web push independently.

+
+
+
+``` + +This is the main anti-glue pattern for dense Tailwind interfaces. diff --git a/docs/UIComponents.md b/docs/UIComponents.md index 37ff5107..ae32acd0 100644 --- a/docs/UIComponents.md +++ b/docs/UIComponents.md @@ -3,6 +3,11 @@ All components are available at runtime via `window.UIComponents` and `window.JSComponents`. Icons are available via `window.LucideReact`. +> Styling note: a module's markup can only use Tailwind classes that Adminizer's own stylesheet +> already contains — the panel's content scan does not cover app modules, so arbitrary values such +> as `text-[11px]` render as nothing. Use an inline style, or ship a module stylesheet through +> `moduleComponentCSS`. See “Styling App Modules” in BuildingModules.md. + ## Runtime access ```js @@ -11,12 +16,21 @@ const { MonacoEditor, MultiSelect } = window.JSComponents; const { Pencil } = window.LucideReact; ``` -## NPM import (after `npm run build`) +## Importing components into a module + +Do **not** import panel components from the `adminizer` package: a module runs inside the +panel and must reuse the instances the panel already mounted (same React, same Radix contexts, +same stylesheet). The supported way is the runtime registry: ```tsx -import { Button } from 'adminizer/ui/button.js'; +const { Button, Dialog, DialogContent } = window.UIComponents; ``` +Types come from `adminizer-module.d.ts` (`import 'adminizer/adminizer-module'` or add the file +to your `tsconfig` `include`). The compiled `ui/components/ui/*.d.ts` files in the package are +shipped for editors that map them through `tsconfig` `paths`; they are declarations only and +have no runtime counterpart. + --- ## window.UIComponents @@ -38,12 +52,17 @@ const { Avatar, AvatarImage, AvatarFallback } = window.UIComponents; ### Badge -Small inline label. Supports `variant`: `default` | `secondary` | `destructive` | `outline`. +Small inline label. Supports `variant`: `default` | `secondary` | `destructive` | `warning` | `outline`. + +Use `warning` for states that need a human but are not errors (waiting for review, pending +approval); it is backed by the `--warning` / `--warning-foreground` palette tokens and follows +light/dark with the panel. ```tsx const { Badge } = window.UIComponents; Error +Needs review Draft ``` @@ -501,14 +520,12 @@ const { Slider } = window.UIComponents; ### Sonner (Toaster) -Toast notifications. Add `` once at the app root, then call `sonner.toast()`. +Toast notifications. The panel layout mounts one `` for every page inside the admin +shell — app modules must **not** mount their own, because every extra `Toaster` subscribes to the +same queue and renders every toast once more. Just call `window.sonner.toast(...)`. ```tsx -const { Toaster } = window.UIComponents; -// In app root: - - -// Anywhere in the app: +// Anywhere in the module — no Toaster to mount: window.sonner.toast('Saved successfully'); window.sonner.toast.error('Something went wrong'); window.sonner.toast.promise(fetchData(), { loading: 'Loading...', success: 'Done', error: 'Failed' }); @@ -727,5 +744,6 @@ const { VanillaJSONEditor } = window.JSComponents; | `window.ReactDOM` | ReactDOM 19 | | `window.LucideReact` | All Lucide icons (`window.LucideReact.Pencil`, etc.) | | `window.InertiajsReact` | Inertia.js React adapter | -| `window.axios` | Axios HTTP client | +| `window.adminApi` | Typed HTTP client for API calls. Use `*Json` methods for JSON endpoints. | +| `window.axios` | Legacy Axios-compatible compatibility client. Logs a deprecation message; prefer `window.adminApi`. | | `window.sonner` | Sonner toast API (`toast`, `toast.error`, `toast.promise`, etc.) | diff --git a/docs/configuration.md b/docs/configuration.md index 6cf1467e..b2cba12d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,852 +1,871 @@ -# Admin Panel configuration - - -### Abstract - -The core principle of configuring Adminizer is based on building a global JSON object, structured according to a specific [TypeScript interface](..\src\interfaces\adminpanelConfig.ts). This object defines and manages the entire behavior and layout of the admin panel. - -The configuration is designed to be simple and declarative: by describing the system through a JavaScript object, you can fully customize the admin panel without modifying the underlying code. Additionally, Adminizer supports extensions — developers can create catalogs, managers, and modules by implementing instances of abstract classes. These extensions are automatically integrated into the interface or runtime of Adminizer when registered in the appropriate handlers. - -In this section, we focus specifically on the fundamental concept — the configuration JSON object. - - -Typescript example: - - -```typescript - -import { AdminizerConfig } from "adminizer"; - -const config: AdminizerConfig = { - routePrefix: "/admin", - auth: true, - dashboard: true, - models: { - ExampleModel: { - title: "Example Models", - model: "ExampleModel", - fields: { - id: { title: "ID", type: "integer" }, - name: { title: "Name", type: "string" }, - description: { title: "Description", type: "text" }, - createdAt: { title: "Created At", type: "datetime" }, - updatedAt: { title: "Updated At", type: "datetime" }, - }, - list: { - fields: { - id: {}, - name: {}, - createdAt: {}, - } - }, - add: true, - edit: true, - remove: true, - view: true, - } - }, - welcome: { - title: "Welcome to Adminizer", - text: "Manage your application easily with Adminizer Admin Panel." - }, - translation: { - locales: ["en"], - defaultLocale: "en", - }, - administrator: { - login: "admin", - password: "admin123", - }, - showVersion: true, -}; -``` - - -## Global configs - -Admin panel configuration consist of this options: - -| Option | Description -|-------------------|-------------------------- -| `routePrefix` | Route prefix for admin panel. Default: `/admin` -| `linkAssets` | Will create a symlink to Admin panel assets. Anyway AP will try to load all assets from /admin/**** and you could copy them manually -| `identifierField` | Default identifier field into models. This field will be used as identifier. Default: `id` -| `models` | Configuration for models. Read below... -| `showORMtime` | Set `true` for enable showing fields createdAt and updatedAt in edit and add sections -## Models - -Admin panel divided into `models` and this is a main part of configuration. - -Model will represent several actions for each model that you need to enable into Admin Panel. -It will consist of actions: - -+ `list` - list of records with filters, pagination and sorting. -+ `add` - add a new records -+ `edit` - editing of record -+ `view` - view details of record -+ `remove` - ability to remove record - -Every model configuration should be placed into `models` block into `config/adminpanel.js` file. -And have a required property `model`. - -``` -module.exports.adminpanel = { - models: { - - users: { // key. No matter what you will write here. just follow JS rules for objects - title: 'Users', // If not defined will be taken from key. Here will be `users` - model: 'User', // !!! required !!! - } - } -}; -``` - -## Actions configuration - -Every action into model could be configured separately. -Actions configuration shuold be placed into `model` block. - -``` -module.exports.adminpanel = { - models: { - - users: { // key. No matter what you will write here. just follow JS rules for objects - title: 'Users', // If not defined will be taken from key. Here will be `users` - model: 'User', // !!! required !!! - - // ==== Actions configuration here ==== - } - } -}; -``` - -Every action could be configured in several ways, **but it should have action key** (list/add/edit/view/remove): - -+ `boolean` - Enable/disable functionality. -``` -models: { - users: { - // ... - - list: true, // will mean that list action should exist. Enabled by default. - edit: false // Will disable edit functionality for model. - } -} -``` - -+ `object` - detailed configuration of action. - -``` -models: { - users: { - list: { - limit: 15, // will set a limit of actions. This option supports only list action ! - fields: {} // list of fields configuration - } - } -} -``` - - -## Fields configuration - -Each field is configured with an **object** (`ModelFieldConfig`): - -``` -fieldName: { - title: "Field title", // overwrite field title - type: "string", // overwrite default field type in admin panel - required: true, // mark field required or not - tooltip: 'tooltip for field', // tooltip for field - editor: true, // add WYSIWYG editor for the field in admin panel - visible: false, // hide field (e.g. on a specific action) -} -``` - -To hide a field, set `visible: false`. To override only the title, pass `{ title: "Field title" }`. - -> **Breaking change in v5:** the boolean (`field: true`/`false`) and string (`field: "Title"`) shorthand notations were removed. Use the object form. A primitive value will be ignored at runtime with a warning. - -**There are several places for field config definition and an inheritance of field configs.** - -+ You could use a global `fields` property into `config/adminpanel.js` file into `models` section. -+ You could use `fields` property into `models:action` configuration. Action level config is shallow-merged on top of the global one. - -``` -module.exports.adminpanel = { - models: { - users: { - title: 'Users', // Menu title for model - model: 'User', // Model definition for model - - fields: { - email: { title: 'User Email' }, // define title for this field in all actions (list/add/edit/view) - createdAt: { visible: false }, // hide createdAt field in all actions - avatar: { - displayModifier: function (img) { // Only for list view, look callback.md for more info - return `` - } - }, - bio: { - title: 'User bio', - type: 'text', // LOOK BELOW FOR TYPES DESCRIPTION - editor: true - } // sets title `User bio` and adds editor in add/edit actions. Could be combined only with `text` type - }, - // Action level config - list: { - fields: { - bio: { visible: false } // hide bio field in list view - } - }, - - edit: { - fields: { - createdAt: { title: 'Created at' } // override title for createdAt in edit - } - } - } - } -} -``` - -## Hide model - -You can hide model from left navbar using `hide` option. - -```javascript -module.exports.adminpanel = { - models: { - users: { - title: 'Users', // Menu title for model - model: 'User', // Model definition for model - hide: true - } - } -} -``` - -## Ignored fields -You can hide fields from all actions by setting `visible: false`. - -```javascript -module.exports.adminpanel = { - models: { - users: { - title: 'Users', // Menu title for model - model: 'User', // Model definition for model - - // these fields will be hidden in all actions - fields: { - 'admin': { visible: false }, - 'someAnotherField': { visible: false }, - 'encryptedPassword': { visible: false } - } - } - } -} -``` - -## Field types - -Field types could be set into `field` configuration or will be inherited from your model definition. - -Now Admin panel supports several field types and add proper editor for every type. - -Types included into admin panel: -+ `string` - textfield into add/edit actions -+ `string` with `isIn` - selectbox -+ `password` - password field -+ `date` - input type date -+ `datetime` - input type datetime -+ `integer` / `float` - input type number -+ `boolean` - checkbox -+ `text` - textarea -+ `select` - html select - -**If you will conbine `text` type with `editor` option for the field admin panel will create a WYSTYG editor for this field.** - -## Select box - -Sails.js Hook adminpanel supports selectboxes. - -If you have `isIn` field in your model it will be displayed into adminpanel as a select box. -You can overwrite `isIn` title using fields configurations: - -Example: - -Your model: -```javascript -module.exports = { - attributes: { - gender: { - type: 'string', - isIn: ['male', 'female'], - required: true - } - } -}; -``` - -Your admin panel configuration: -```javascript -module.exports.adminpanel = { - models: { - users: { - title: 'Users', // Menu title for model - model: 'User', // Model definition for model - - fields: { - 'gender': { - isIn: { - male: 'Male', - female: 'Female' - } - } - } - } - } -} -``` - -## Select many -You need configure you field as json - -Your model: -```javascript -module.exports = { - attributes: { - contactType: { - type: 'json', - } - } -}; -``` - -Your admin panel configuration: -You need configure isIn option for you filed as plain object {} or array of strings - -```javascript -module.exports.adminpanel = { - models: { - users: { - title: 'Users', // Menu title for model - model: 'User', // Model definition for model - - fields: { - 'contactType': { - isIn: { - 'email': 'E-Mail', - 'phone': 'Phone', - 'sms': 'SMS' - } - } - } - } - } -} -``` - -## Associations - -Now Adminpanel hook partially supports assotiations. - -Adminpanel determinates such fields by waterline configuration. -Right now if you model field have such configuration: - -```javascript -fieldName: { - model: 'SomeModel' -} -``` - -Admin panel will create select list for `add/edit` actions and will populate record for `list/view` actions. - -Available configuration options: -+ `title` - Default title option - -Deprecated options: `identifierField`, `displayField` was mowed in model config - -Example: -```javascript -owner: { - title: 'Owner' -} -``` - -## Auto config - -This configuration loads all sail models as they are. Just place in `config\adminpanel.js` - -```javascript - const fs = require("fs"); - let modelPath = __dirname + "/../api/models"; - let models = {}; - fs.readdir(modelPath, function (err, files) { - files.forEach(function (file) { - let modelName = file.split(".")[0]; - models[modelName] = { - title: modelName, - model: modelName, - }; - }); - }); - - module.exports.adminpanel = { models: models } ; -``` - - -## Limitations - -+ For now admin panel do not fully support waterline associations. So some fields might be ignored ! It's planned. -+ No file upload functionality. Due to the nature of `skipper` I removed file upload functionality. **Not tested with latest one** -+ No custom actions support. For now you couldn't add custom actions and pages into admin panel. -+ No template engine support except of `jade`. I primary working with this template engine and didn't create a another templates -+ No custom assets support. You couldn't edit css/js for admin panel for now. - -## Configuration schema - - -```javascript -// @ts-check -// import { AdminizerConfig } from "adminizer"; // Import the type via JSDoc comment - -/** @type {import("adminizer").AdminizerConfig} */ -const config = { - routePrefix: "/admin", - auth: true, - dashboard: true, - models: { - ExampleModel: { - title: "Example Models", - model: "ExampleModel", - fields: { - id: { title: "ID", type: "integer" }, - name: { title: "Name", type: "string" }, - description: { title: "Description", type: "text" }, - createdAt: { title: "Created At", type: "datetime" }, - updatedAt: { title: "Updated At", type: "datetime" }, - }, - list: { - fields: { - id: {}, - name: {}, - createdAt: {}, - } - }, - add: true, - edit: true, - remove: true, - view: true, - } - }, - welcome: { - title: "Welcome to Adminizer", - text: "Manage your application easily with Adminizer Admin Panel." - }, - translation: { - locales: ["en"], - defaultLocale: "en", - }, - administrator: { - login: "admin", - password: "admin123", - }, - showVersion: true, -}; - -module.exports = config; -``` - - -```javascript - -{ - models: { - [key:string]: { - title: string - model: string // Model name - hide: boolean // Hide model in left navbar - fields: { - [key: string]: { - title: string - type: FieldsTypes // all fields types are below this config - tooltip: string // Field description - // Options for widgets like 'Navigation', 'Schedule' and 'FileUploader'. For more - // information open Navigation.md or Schedule.md - options: NavigationOptionsField | ScheduleOptionsField | FileUploaderOptionsField - displayModifier: ()=>void // Function that makes data modification on list view - }[] | boolean | string - } - list: { // List display configuration - fields: { - [key: string]: { - title: string - type: FieldsTypes // all fields types are below this config - tooltip: string // Field description - // Options for widgets like 'Navigation', 'Schedule' and 'FileUploader'. For more - // information open Navigation.md or Schedule.md - options: NavigationOptionsField | ScheduleOptionsField | FileUploaderOptionsField - displayModifier: ()=>void // Function that makes data modification on list view - }[] | boolean | string - } - actions: { // Actions configuration that will be displayed - global: { - id: string - title: string - link: string - icon: string - // Only for view, controller still uses his own access rights token - accessRightsToken: string - }[] - inline: { - id: string - title: string - link: string - icon: string - // Only for view, controller still uses his own access rights token - accessRightsToken: string - }[] - } - } | boolean - // Configuration for 'create model' action or disabling/enabling it - add: { - fields: { - [key: string]: { - title: string - type: FieldsTypes // all fields types are below this config - tooltip: string // Field description - // Options for widgets like 'Navigation', 'Schedule' and 'FileUploader'. For more - // information open Navigation.md or Schedule.md - options: NavigationOptionsField | ScheduleOptionsField | FileUploaderOptionsField - displayModifier: ()=>void // Function that makes data modification on list view - }[] | boolean | string - } - modelModifier: ()=>void // callback for data modification before saving record - controller: string // path to custom controller - } | boolean - // Configuration for 'update model' action or disabling/enabling it - edit: { - fields: { - [key: string]: { - title: string - type: FieldsTypes // all fields types are below this config - tooltip: string // Field description - // Options for widgets like 'Navigation', 'Schedule' and 'FileUploader'. For more - // information open Navigation.md or Schedule.md - options: NavigationOptionsField | ScheduleOptionsField | FileUploaderOptionsField - displayModifier: ()=>void // Function that makes data modification on list view - }[] | boolean | string - } - modelModifier: ()=>void // callback for data modification before saving record - controller: string // // path to custom controller - } | boolean - remove: boolean // Disabling/enabling 'delete model' action - view: boolean // Disabling/enabling 'read model' action - tools: { // Model actions displayed in left navbar for specific model - id: string - title: string - link: string - icon: string - // Only for view, controller still uses his own access rights token - accessRightsToken: string - }[] - icon: string // Model icon - identifierField: string // Force set primary key - titleField: string // Title field to replace ID in relation and display in list - }[] - } - sections: { // For custom adminpanel sections, displays inside header - id: string - title: string - link: string - icon: string - // Only for view, controller still uses his own access rights token - accessRightsToken: string - }[] - routePrefix: string // Route prefix for adminpanel, admin by default - pathToViews: string // Relative path from project root to views folder - identifierField: string // Force set primary key - brand: { - link: boolean | string | { - id: string - title: string - link: string - icon: string - // Only for view, controller still uses his own access rights token - accessRightsToken: string - } - } - navbar: { // Left-side navigation bar - additionalLinks: { // static links shown in the sidenav panel - id: string - title: string - link: string - type: 'self' | 'blank' - icon?: string - section?: string - accessRightsToken?: string - subItems?: HrefConfig[] - }[] - // Called after all links (static + model-generated) are collected. Returns final array. - handleAdditionalLinks: (user: UserAP, allLinks: HrefConfig[]) => HrefConfig[] - // Per-section handlers, applied after all links are collected. - sectionHandlers: { - [section: string]: (user: UserAP, links: HrefConfig[]) => HrefConfig[] - } - } - // Policies that will be executed before going to every page - policies: string | string[] | Function | Function[] - styles: string[] // custom adminpanel styles - script: { // custom adminpanel scripts - header: string[] - footer: string[] - } - welcome: { // Text for welcome page - title: string - text: string - } - translation: { // Text translation using sails built-in internationalization - locales: string[] // Locales list - path: string // Relative path from project root to translations folder - defaultLocale: string // Default locale - } - // default administrator login credentials, will be used if no admin profiles found - administrator: { - login: string - password: string - } - // forms - forms: { - path: string - data: object - get: ()=>void - set: ()=>void - } - // Enable/disable displaying createdAt and updatedAt fields in `edit` and `add` sections - showORMtime: boolean - package: any // Adminpanel package.json config - timezones: { // Available timezones list - id: string - name: string - }[] - showVersion: boolean // Show adminpanel version on the bottom of navbar -} -``` - -### FieldsTypes - -string, password, date, datetime, time, integer, number, float, color, email, month, week, -range, boolean, binary, text, longtext, mediumtext, ckeditor, wysiwyg, texteditor, word, -jsoneditor, json, array, object, ace, html, xml, aceeditor, image, images, file, files, table -menu, navigation, schedule, worktime, association, "association-many", select, select-many - - -### Only routes create -If the value of the model key is `true: boolean` then only add and edit routers will be created, as well as the corresponding rights for them - - -# Custom links -You can add custom links into your admin panel pages. - -You could use: -- `additionalLinks` in `navbar` to define static links in the sidenav panel -- `handleAdditionalLinks(user, allLinks)` in `navbar` to filter or transform all navbar links (static + model-generated) after all links are collected -- `sectionHandlers` in `navbar` to apply a handler to links of a specific section, after all links are collected -- `global` or `inline` actions in `actions` property of `list` view -- `tools` property to create link like Model submenu - -## Action buttons - -```javascript -module.exports.adminpanel = { - navbar: { - additionalLinks: [ - { - id: '1', - title: "First action", - link: string, - icon: "", - subItems: HrefConfig[], // second level links like Model tools - accessRightsToken: "firstLinkToken" - } - ] - }, - models: { - pages: { - title: 'MediaManager', - model: 'Item', - tools: [ - { - id: "0", - link: "/", - title: "Some new action", - icon: "ok", - accessRightsToken: "someLinkToken" - } - ], - - list: { - actions: { - // Actions in top right corner - global: [ - { - id: '2', - link: '/', - title: 'Some new action', - icon: 'ok', - accessRightsToken: "secondLinkToken" - } - ], - // Inline actions for every - inline: [ - { - id: '2', - link: '/', - title: 'Something', // Will be added as alt to img - icon: 'trash', - accessRightsToken: "thirdLinkToken" - } - ] - } - } - } - } -}; -``` - -## Dynamic navbar links by user - -Use `handleAdditionalLinks` to filter or transform the full list of navbar links (static + model-generated) based on the current user. The callback receives all links after models have been processed and must return the final array. - -```javascript -module.exports.adminpanel = { - navbar: { - additionalLinks: [ - { - id: "help", - title: "Help center", - link: "/adminizer/help", - type: "self", - icon: "help", - section: "Support" - } - ], - handleAdditionalLinks: function (user, allLinks) { - const isAdministrator = Boolean(user?.isAdministrator); - - if (!isAdministrator) { - // Remove links that require admin access - return allLinks.filter(link => link.id !== 'audit'); - } - - return [ - ...allLinks, - { - id: "audit", - title: "Audit log", - link: "/adminizer/history", - type: "self", - icon: "history", - accessRightsToken: "read-history", - section: "Monitoring" - } - ]; - } - } -}; -``` - -## Per-section link handlers - -Use `sectionHandlers` to apply a handler only to links belonging to a specific section. Each handler receives the links of that section and the current user, and returns the filtered or modified list. Handlers run after all links (static + model-generated) are collected. - -```javascript -module.exports.adminpanel = { - navbar: { - additionalLinks: [ - { - id: "status", - title: "System status", - link: "https://status.example.com", - type: "blank", - icon: "monitoring", - section: "Support" - } - ], - sectionHandlers: { - Support: function (user, links) { - // Only show support links to non-admin users - if (user?.isAdministrator) return []; - return links; - }, - Platform: function (user, links) { - // Filter platform links based on user group - return links.filter(link => - !link.accessRightsToken || user?.groups?.includes('editors') - ); - } - } - } -}; -``` - -All links can be assigned to a sidebar section via `section` field on `HrefConfig`: -```javascript -{ - id: "billing", - title: "Billing", - link: "/adminizer/billing", - type: "self", - section: "Finance" -} -``` - -Order of processing: -1. Static `additionalLinks` are added to the navbar -2. Model-generated links are appended -3. `handleAdditionalLinks(user, allLinks)` is applied to the full list -4. `sectionHandlers` are applied per section - -# Edit callback - -ModelModifier - function in adminpanel config edit sections for modification Model data -before save in database. - -``` -module.exports.adminpanel = { - models: { - users: { - title: 'Users', - model: 'User', - add: { - fields: { - }, - // saved object to be modificated before save in database - ModelModifier: function (Model) { - Model.human_edited = true; - return Model; - }, - } - } - } -} - -``` - - -# Display modifier - -It uses to manage fields' views - -```javascript -displayModifier: function (data) { - // for list view - if (Array.isArray(data)) { - data = data.map((item) => {return item.label + item.date}) - return data.join(',') - } - // for edit view - return data.label + data.date -} -``` +# Admin Panel configuration + +> This page is a legacy extended reference. Prefer the focused pages in `docs/Configuration/` for new projects. Sequelize is the primary supported ORM; TypeORM support is experimental. + + +### Abstract + +The core principle of configuring Adminizer is based on building a global JSON object, structured according to a specific [TypeScript interface](..\src\interfaces\adminpanelConfig.ts). This object defines and manages the entire behavior and layout of the admin panel. + +The configuration is designed to be simple and declarative: by describing the system through a JavaScript object, you can fully customize the admin panel without modifying the underlying code. Additionally, Adminizer supports extensions — developers can create catalogs, managers, and modules by implementing instances of abstract classes. These extensions are automatically integrated into the interface or runtime of Adminizer when registered in the appropriate handlers. + +In this section, we focus specifically on the fundamental concept — the configuration JSON object. + + +Typescript example: + + +```typescript + +import { AdminizerConfig } from "adminizer"; + +const config: AdminizerConfig = { + routePrefix: "/admin", + auth: { enable: true }, + dashboard: true, + models: { + ExampleModel: { + title: "Example Models", + model: "ExampleModel", + fields: { + id: { title: "ID", type: "integer" }, + name: { title: "Name", type: "string" }, + description: { title: "Description", type: "text" }, + createdAt: { title: "Created At", type: "datetime" }, + updatedAt: { title: "Updated At", type: "datetime" }, + }, + list: { + fields: { + id: {}, + name: {}, + createdAt: {}, + } + }, + add: true, + edit: true, + remove: true, + view: true, + } + }, + welcome: { + title: "Welcome to Adminizer", + text: "Manage your application easily with Adminizer Admin Panel." + }, + translation: { + locales: ["en"], + defaultLocale: "en", + }, + administrator: { + login: "admin", + password: "admin123", + }, + showVersion: true, +}; +``` + + +## Global configs + +Admin panel configuration consist of this options: + +| Option | Description +|-------------------|-------------------------- +| `routePrefix` | Route prefix for admin panel. Default: `/admin` +| `linkAssets` | Will create a symlink to Admin panel assets. Anyway AP will try to load all assets from /admin/**** and you could copy them manually +| `identifierField` | Default identifier field into models. This field will be used as identifier. Default: `id` +| `models` | Configuration for models. Read below... +| `showORMtime` | Set `true` for enable showing fields createdAt and updatedAt in edit and add sections + +### List defaults + +Use the global `list.defaultPageSize` option to set how many records are shown on model list pages by default. Supported values are `5`, `20`, and `50`; the default value is `50`. + +```typescript +const config: AdminizerConfig = { + list: { + defaultPageSize: 20, + }, + models: { + // ... + }, +}; +``` + +## Models + +Admin panel divided into `models` and this is a main part of configuration. + +Model will represent several actions for each model that you need to enable into Admin Panel. +It will consist of actions: + ++ `list` - list of records with filters, pagination and sorting. ++ `add` - add a new records ++ `edit` - editing of record ++ `view` - view details of record ++ `remove` - ability to remove record + +Every model configuration should be placed into `models` block into `config/adminpanel.js` file. +And have a required property `model`. + +``` +module.exports.adminpanel = { + models: { + + users: { // canonical Adminizer resource name; it controls URL and permissions + title: 'Users', // If not defined will be taken from key. Here will be `users` + model: 'User', // !!! required !!! + } + } +}; +``` + +## Actions configuration + +Every action into model could be configured separately. +Actions configuration shuold be placed into `model` block. + +``` +module.exports.adminpanel = { + models: { + + users: { // key. No matter what you will write here. just follow JS rules for objects + title: 'Users', // If not defined will be taken from key. Here will be `users` + model: 'User', // !!! required !!! + + // ==== Actions configuration here ==== + } + } +}; +``` + +Every action could be configured in several ways, **but it should have action key** (list/add/edit/view/remove): + ++ `boolean` - Enable/disable functionality. +``` +models: { + users: { + // ... + + list: true, // will mean that list action should exist. Enabled by default. + edit: false // Will disable edit functionality for model. + } +} +``` + ++ `object` - detailed configuration of action. + +``` +models: { + users: { + list: { + limit: 15, // will set a limit of actions. This option supports only list action ! + fields: {} // list of fields configuration + } + } +} +``` + + +## Fields configuration + +Each field is configured with an **object** (`ModelFieldConfig`): + +``` +fieldName: { + title: "Field title", // overwrite field title + type: "string", // overwrite default field type in admin panel + required: true, // mark field required or not + tooltip: 'tooltip for field', // tooltip for field + editor: true, // add WYSIWYG editor for the field in admin panel + visible: false, // hide field (e.g. on a specific action) +} +``` + +To hide a field, set `visible: false`. To override only the title, pass `{ title: "Field title" }`. + +> **Breaking change in v5:** the boolean (`field: true`/`false`) and string (`field: "Title"`) shorthand notations were removed. Use the object form. A primitive value will be ignored at runtime with a warning. + +**There are several places for field config definition and an inheritance of field configs.** + ++ You could use a global `fields` property into `config/adminpanel.js` file into `models` section. ++ You could use `fields` property into `models:action` configuration. Action level config is shallow-merged on top of the global one. + +``` +module.exports.adminpanel = { + models: { + users: { + title: 'Users', // Menu title for model + model: 'User', // Model definition for model + + fields: { + email: { title: 'User Email' }, // define title for this field in all actions (list/add/edit/view) + createdAt: { visible: false }, // hide createdAt field in all actions + avatar: { + displayModifier: function (img) { // Only for list view, look callback.md for more info + return `` + } + }, + bio: { + title: 'User bio', + type: 'text', // LOOK BELOW FOR TYPES DESCRIPTION + editor: true + } // sets title `User bio` and adds editor in add/edit actions. Could be combined only with `text` type + }, + // Action level config + list: { + fields: { + bio: { visible: false } // hide bio field in list view + } + }, + + edit: { + fields: { + createdAt: { title: 'Created at' } // override title for createdAt in edit + } + } + } + } +} +``` + +## Hide model + +You can hide a model from the left navbar with `navbar.visible: false`, or show its item only to +some groups with `navbar.groupsAccessRights`. + +```javascript +module.exports.adminpanel = { + models: { + users: { + title: 'Users', // Menu title for model + model: 'User', // Model definition for model + navbar: { + visible: false + } + } + } +} +``` + +A model hidden this way is also left out of the navigation registry (`AdminLinkHandler`): it +gets no automatic `model--edit` / `model--add` link templates, so it does not +appear in the global search, the assistant's navigation or auto breadcrumbs. Its routes and +permissions are unaffected — the pages still open by URL. + +## Ignored fields +You can hide fields from all actions by setting `visible: false`. + +```javascript +module.exports.adminpanel = { + models: { + users: { + title: 'Users', // Menu title for model + model: 'User', // Model definition for model + + // these fields will be hidden in all actions + fields: { + 'admin': { visible: false }, + 'someAnotherField': { visible: false }, + 'encryptedPassword': { visible: false } + } + } + } +} +``` + +## Field types + +Field types could be set into `field` configuration or will be inherited from your model definition. + +Now Admin panel supports several field types and add proper editor for every type. + +Types included into admin panel: ++ `string` - textfield into add/edit actions ++ `string` with `isIn` - selectbox ++ `password` - password field ++ `date` - input type date ++ `datetime` - input type datetime ++ `integer` / `float` - input type number ++ `boolean` - checkbox ++ `text` - textarea ++ `select` - html select + +**If you will conbine `text` type with `editor` option for the field admin panel will create a WYSTYG editor for this field.** + +## Select box + +Sails.js Hook adminpanel supports selectboxes. + +If you have `isIn` field in your model it will be displayed into adminpanel as a select box. +You can overwrite `isIn` title using fields configurations: + +Example: + +Your model: +```javascript +module.exports = { + attributes: { + gender: { + type: 'string', + isIn: ['male', 'female'], + required: true + } + } +}; +``` + +Your admin panel configuration: +```javascript +module.exports.adminpanel = { + models: { + users: { + title: 'Users', // Menu title for model + model: 'User', // Model definition for model + + fields: { + 'gender': { + isIn: { + male: 'Male', + female: 'Female' + } + } + } + } + } +} +``` + +## Select many +You need configure you field as json + +Your model: +```javascript +module.exports = { + attributes: { + contactType: { + type: 'json', + } + } +}; +``` + +Your admin panel configuration: +You need configure isIn option for you filed as plain object {} or array of strings + +```javascript +module.exports.adminpanel = { + models: { + users: { + title: 'Users', // Menu title for model + model: 'User', // Model definition for model + + fields: { + 'contactType': { + isIn: { + 'email': 'E-Mail', + 'phone': 'Phone', + 'sms': 'SMS' + } + } + } + } + } +} +``` + +## Associations + +Adminizer partially supports associations. + +Adminizer detects association fields from adapter metadata. For example, a relation field may be represented as: + +```javascript +fieldName: { + model: 'SomeModel' +} +``` + +Admin panel will create select list for `add/edit` actions and will populate record for `list/view` actions. + +Available configuration options: ++ `title` - Default title option + +Deprecated options: `identifierField`, `displayField` was mowed in model config + +Example: +```javascript +owner: { + title: 'Owner' +} +``` + +## Auto config + +The old Sails auto-config example is not applicable to current Adminizer projects. Define models explicitly in `AdminpanelConfig.models`, or generate that object from your ORM metadata in project code. + +```ts +const config = { + models: { + Example: { + title: "Example", + model: "example", + }, + }, +}; +``` + + +## Limitations + ++ Association support depends on the active adapter and model metadata. ++ Custom controls, modules, widgets, media manager uploads, and custom assets are supported by current Adminizer versions. See the focused documentation pages for current APIs. + +## Configuration schema + + +```javascript +// @ts-check +// import { AdminizerConfig } from "adminizer"; // Import the type via JSDoc comment + +/** @type {import("adminizer").AdminizerConfig} */ +const config = { + routePrefix: "/admin", + auth: { enable: true }, + dashboard: true, + models: { + ExampleModel: { + title: "Example Models", + model: "ExampleModel", + fields: { + id: { title: "ID", type: "integer" }, + name: { title: "Name", type: "string" }, + description: { title: "Description", type: "text" }, + createdAt: { title: "Created At", type: "datetime" }, + updatedAt: { title: "Updated At", type: "datetime" }, + }, + list: { + fields: { + id: {}, + name: {}, + createdAt: {}, + } + }, + add: true, + edit: true, + remove: true, + view: true, + } + }, + welcome: { + title: "Welcome to Adminizer", + text: "Manage your application easily with Adminizer Admin Panel." + }, + translation: { + locales: ["en"], + defaultLocale: "en", + }, + administrator: { + login: "admin", + password: "admin123", + }, + showVersion: true, +}; + +module.exports = config; +``` + + +```javascript + +{ + models: { + [key:string]: { + title: string + model: string // Model name + navbar: { + visible: boolean // false hides the model from the navbar and the navigation registry (search, assistant, breadcrumbs) + groupsAccessRights: string[] // Groups that see the menu item; others get neither the item nor the model's link templates + section: string // Navbar section of the item + } + context: string // Layout context the model's pages belong to: they always render in it, and users who may not enter it get 403 (see Layout Contexts.md) + fields: { + [key: string]: { + title: string + type: FieldsTypes // all fields types are below this config + tooltip: string // Field description + // Options for widgets like 'Navigation', 'Schedule' and 'FileUploader'. For more + // information open Navigation.md or Schedule.md + options: NavigationOptionsField | ScheduleOptionsField | FileUploaderOptionsField + displayModifier: ()=>void // Function that makes data modification on list view + }[] | boolean | string + } + list: { // List display configuration + fields: { + [key: string]: { + title: string + type: FieldsTypes // all fields types are below this config + tooltip: string // Field description + // Options for widgets like 'Navigation', 'Schedule' and 'FileUploader'. For more + // information open Navigation.md or Schedule.md + options: NavigationOptionsField | ScheduleOptionsField | FileUploaderOptionsField + displayModifier: ()=>void // Function that makes data modification on list view + }[] | boolean | string + } + actions: { // Actions configuration that will be displayed + global: { + id: string + title: string + link: string + icon: string + // Only for view, controller still uses his own access rights token + accessRightsToken: string + }[] + inline: { + id: string + title: string + link: string + icon: string + // Only for view, controller still uses his own access rights token + accessRightsToken: string + }[] + } + } | boolean + // Configuration for 'create model' action or disabling/enabling it + add: { + fields: { + [key: string]: { + title: string + type: FieldsTypes // all fields types are below this config + tooltip: string // Field description + // Options for widgets like 'Navigation', 'Schedule' and 'FileUploader'. For more + // information open Navigation.md or Schedule.md + options: NavigationOptionsField | ScheduleOptionsField | FileUploaderOptionsField + displayModifier: ()=>void // Function that makes data modification on list view + }[] | boolean | string + } + modelModifier: ()=>void // callback for data modification before saving record + controller: string // path to custom controller + } | boolean + // Configuration for 'update model' action or disabling/enabling it + edit: { + fields: { + [key: string]: { + title: string + type: FieldsTypes // all fields types are below this config + tooltip: string // Field description + // Options for widgets like 'Navigation', 'Schedule' and 'FileUploader'. For more + // information open Navigation.md or Schedule.md + options: NavigationOptionsField | ScheduleOptionsField | FileUploaderOptionsField + displayModifier: ()=>void // Function that makes data modification on list view + }[] | boolean | string + } + modelModifier: ()=>void // callback for data modification before saving record + controller: string // // path to custom controller + } | boolean + remove: boolean // Disabling/enabling 'delete model' action + view: boolean // Disabling/enabling 'read model' action + tools: { // Model actions displayed in left navbar for specific model + id: string + title: string + link: string + icon: string + // Only for view, controller still uses his own access rights token + accessRightsToken: string + }[] + icon: string // Model icon + identifierField: string // Force set primary key + titleField: string // Title field to replace ID in relation and display in list + }[] + } + sections: { // For custom adminpanel sections, displays inside header + id: string + title: string + link: string + icon: string + // Only for view, controller still uses his own access rights token + accessRightsToken: string + }[] + routePrefix: string // Route prefix for adminpanel, admin by default + pathToViews: string // Relative path from project root to views folder + identifierField: string // Force set primary key + brand: { + link: boolean | string | { + id: string + title: string + link: string + icon: string + // Only for view, controller still uses his own access rights token + accessRightsToken: string + } + } + navbar: { // Left-side navigation bar + additionalLinks: { // static links shown in the sidenav panel + id: string + title: string + link: string + type: 'self' | 'blank' + icon?: string + section?: string + accessRightsToken?: string + subItems?: HrefConfig[] + badge?: number | string | ((user: User) => number | string | undefined | Promise) // count next to the item, or a per-user resolver; 0/'' render nothing + }[] + // Called after all links (static + model-generated) are collected. Returns final array. + handleAdditionalLinks: (user: User, allLinks: HrefConfig[]) => HrefConfig[] + // Per-section handlers, applied after all links are collected. + sectionHandlers: { + [section: string]: (user: User, links: HrefConfig[]) => HrefConfig[] + } + breadcrumbs?: 'manual' | 'auto' // 'auto' derives crumbs from the navigation registry + + } + // Policies that will be executed before going to every page + policies: string | string[] | Function | Function[] + styles: string[] // custom adminpanel styles + script: { // custom adminpanel scripts + header: string[] + footer: string[] + } + welcome: { // Text for welcome page + title: string + text: string + } + translation: { // Text translation + locales: string[] // Locales list + directory: string // Relative path from project root to translations folder + defaultLocale: string // Default locale + } + // default administrator login credentials, will be used if no admin profiles found + administrator: { + login: string + password: string + } + // Legacy configuration-driven forms are no longer used starting with Adminizer 5. + // Enable/disable displaying createdAt and updatedAt fields in `edit` and `add` sections + showORMtime: boolean + package: any // Adminpanel package.json config + timezones: { // Available timezones list + id: string + name: string + }[] + showVersion: boolean // Show adminpanel version on the bottom of navbar +} +``` + +### FieldsTypes + +string, password, date, datetime, time, integer, number, float, color, email, month, week, +range, boolean, binary, text, longtext, mediumtext, ckeditor, wysiwyg, texteditor, word, +jsoneditor, json, array, object, ace, html, xml, aceeditor, image, images, file, files, table +menu, navigation, schedule, worktime, association, "association-many", select, select-many + + +### Only routes create +If the value of the model key is `true: boolean` then only add and edit routers will be created, as well as the corresponding rights for them + + +# Custom links +You can add custom links into your admin panel pages. + +You could use: +- `additionalLinks` in `navbar` to define static links in the sidenav panel +- `handleAdditionalLinks(user, allLinks)` in `navbar` to filter or transform all navbar links (static + model-generated) after all links are collected +- `sectionHandlers` in `navbar` to apply a handler to links of a specific section, after all links are collected +- `badge` on any link (`additionalLinks`, `ctx.adminLink`, items returned by `handleAdditionalLinks`) shows a count in the expanded sidebar. Pass a number/string, or a resolver `(user) => count | Promise` for a per-user value: it runs on every request, only for items the user may open, so return a cached count rather than querying per call; a resolver that throws is logged and renders nothing +- `global` or `inline` actions in `actions` property of `list` view +- `tools` property to create link like Model submenu + +## Action buttons + +```javascript +module.exports.adminpanel = { + navbar: { + additionalLinks: [ + { + id: '1', + title: "First action", + link: string, + icon: "", + subItems: HrefConfig[], // second level links like Model tools + accessRightsToken: "firstLinkToken" + } + ] + }, + models: { + pages: { + title: 'MediaManager', + model: 'Item', + tools: [ + { + id: "0", + link: "/", + title: "Some new action", + icon: "ok", + accessRightsToken: "someLinkToken" + } + ], + + list: { + actions: { + // Actions in top right corner + global: [ + { + id: '2', + link: '/', + title: 'Some new action', + icon: 'ok', + accessRightsToken: "secondLinkToken" + } + ], + // Inline actions for every + inline: [ + { + id: '2', + link: '/', + title: 'Something', // Will be added as alt to img + icon: 'trash', + accessRightsToken: "thirdLinkToken" + } + ] + } + } + } + } +}; +``` + +## Dynamic navbar links by user + +Use `handleAdditionalLinks` to filter or transform the full list of navbar links (static + model-generated) based on the current user. The callback receives all links after models have been processed and must return the final array. + +```javascript +module.exports.adminpanel = { + navbar: { + additionalLinks: [ + { + id: "help", + title: "Help center", + link: "/adminizer/help", + type: "self", + icon: "help", + section: "Support" + } + ], + handleAdditionalLinks: function (user, allLinks) { + const isAdministrator = Boolean(user?.isAdministrator); + + if (!isAdministrator) { + // Remove links that require admin access + return allLinks.filter(link => link.id !== 'audit'); + } + + return [ + ...allLinks, + { + id: "audit", + title: "Audit log", + link: "/adminizer/history", + type: "self", + icon: "history", + accessRightsToken: "read-history", + section: "Monitoring" + } + ]; + } + } +}; +``` + +## Per-section link handlers + +Use `sectionHandlers` to apply a handler only to links belonging to a specific section. Each handler receives the links of that section and the current user, and returns the filtered or modified list. Handlers run after all links (static + model-generated) are collected. + +```javascript +module.exports.adminpanel = { + navbar: { + additionalLinks: [ + { + id: "status", + title: "System status", + link: "https://status.example.com", + type: "blank", + icon: "monitoring", + section: "Support" + } + ], + sectionHandlers: { + Support: function (user, links) { + // Only show support links to non-admin users + if (user?.isAdministrator) return []; + return links; + }, + Platform: function (user, links) { + // Filter platform links based on user group + return links.filter(link => + !link.accessRightsToken || user?.groups?.includes('editors') + ); + } + } + } +}; +``` + +All links can be assigned to a sidebar section via `section` field on `HrefConfig`: +```javascript +{ + id: "billing", + title: "Billing", + link: "/adminizer/billing", + type: "self", + section: "Finance" +} +``` + +Order of processing: +1. Static `additionalLinks` are added to the navbar +2. Model-generated links are appended +3. `handleAdditionalLinks(user, allLinks)` is applied to the full list +4. `sectionHandlers` are applied per section + +# Edit callback + +ModelModifier - function in adminpanel config edit sections for modification Model data +before save in database. + +``` +module.exports.adminpanel = { + models: { + users: { + title: 'Users', + model: 'User', + add: { + fields: { + }, + // saved object to be modificated before save in database + ModelModifier: function (Model) { + Model.human_edited = true; + return Model; + }, + } + } + } +} + +``` + + +# Display modifier + +It uses to manage fields' views + +```javascript +displayModifier: function (data) { + // for list view + if (Array.isArray(data)) { + data = data.map((item) => {return item.label + item.date}) + return data.join(',') + } + // for edit view + return data.label + data.date +} +``` diff --git a/docs/development.md b/docs/development.md index cd117f4e..f593dd39 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,45 +1,68 @@ ## Development + - **`npm run dev`** - Starts the application in development mode using Sequelize with file watching. -- **`npm run dev:waterline`** - Starts the application in development mode using Waterline with file watching. - -- **`npm run watch:backend`** - Watches for changes in backend files and recompiles TypeScript continuously. - -## Build Commands -- **`npm run build:assets`** - Builds frontend assets using Vite. - -- **`npm run build:backend`** - Combines backend copy and compilation steps. - -- **`npm run build`** - Full build process: copies backend, compiles backend, and builds assets. - -## Module-specific Builds (this is just for tests, as an example, you should create your own commands to build modules) -- **`npm run build:module`** - Builds test modules using a custom Vite config. - -- **`npm run build:react-quill`** - Builds the React-Quill WYSIWYG module using a custom Vite config. - -- **`npm run copy:modules`** - Copies modules using `copy-modules.js` script. - -- **`npm run build:assets:modules`** - Builds assets and copies modules. - -## Demo & Seeding -- **`npm run start:seed`** - Starts the application with seed data enabled (using `SEED_DATA=true`). - -- **`npm run demo:build`** - Prepares a demo build: copies backend, compiles backend, builds assets, and copies modules. - -- **`npm run demo`** - Alias for `start:seed` - runs the demo with seeded data. - -## Preview mode -- **`npm start`** - Starts the application using TSX with the Waterline fixture configuration. + Starts the Sequelize fixture in development mode with file watching and seed data. + +- **`npm run dev:no-seed`** + Starts the Sequelize fixture in development mode without seed data. + +- **`npm run dev:typeorm`** + Starts the experimental TypeORM fixture in development mode with file watching and seed data. + +- **`npm run dev:typeorm:no-seed`** + Starts the experimental TypeORM fixture in development mode without seed data. + +- **`npm run dev:cors`** + Starts the Sequelize fixture with CORS settings for frontend integration testing. + +- **`npm run dev:no-seed-clean`** + Starts the Sequelize fixture without seed data and removes `.tmp` before startup. + +- **`npm run tsc:watch`** + Watches backend TypeScript and recompiles continuously. + +Adminizer uses `ADMINIZER_ENV=dev` to switch on development behavior such as Vite middleware and app asset `devUrl` paths. + +## Build Commands + +- **`npm run build`** + Full package build: copy backend, compile backend, compile UI, and build frontend assets. + +- **`npm run build:backend`** + Copy backend files and compile backend TypeScript. + +- **`npm run compile:backend`** + Compile backend TypeScript with `src/tsconfig.json` and append `.js` extensions in generated imports. + +- **`npm run compile:ui`** + Compile the shared UI TypeScript package. + +- **`npm run build:assets`** + Build the main Adminizer frontend assets and all built-in control ES modules with Vite. + +- **`npm run build:controls`** + Build only the built-in control ES modules and their styles. + +## App And Module Builds + +- **`npm run build:apps`** + Builds all fixture app frontend modules and fixture virtual catalog components: notification-sender, module-manager, navigation catalog templates, React-Quill, and virtual catalog React templates/actions. + +- **`npm run build:catalog-modules`** + Builds fixture virtual catalog React components from `fixture/virtual-catalog`. + +Project modules should usually define their own Vite build script. See [BuildingModules.md](BuildingModules.md) for current `AbstractAdminizerApp` and `AppManager` usage. + +## Start And Seeding + +- **`npm run start`** + Starts the Sequelize fixture without seed data. + +- **`npm run start:seed`** + Starts the Sequelize fixture with seed data. + +- **`npm run start:typeorm`** + Starts the experimental TypeORM fixture without seed data. + +- **`npm run start:typeorm:seed`** + Starts the experimental TypeORM fixture with seed data. diff --git a/docs/index.md b/docs/index.md index 7816e569..df02c985 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,4 +1,4 @@ -# 📘 Adminizer Documentation +# Adminizer Documentation ## 1. Getting Started @@ -9,29 +9,55 @@ * [General Settings](Configuration/General.md) * [Model Definitions](Configuration/Models.md) * [Field Options](Configuration/Fields.md) +* [Inline Editing](Configuration/InlineEditing.md) * [Custom Components](Configuration/CustomComponents.md) +* [Localization](Configuration/Localization.md) -## Other +## 3. Extension Points -* [Localization](Configuration/Localization.md) +* [App Modules](BuildingModules.md) +* [Catalogs](Catalog.md) +* [Navigation Module](Navigation.md) +* [Layout Contexts](Layout%20Contexts.md) +* [Media Manager](MediaManager.md) +* [Admin Controls](Controls.md) +* [Widgets](Configuration/Widgets.md) +* [Global UI Components](GlobalUI.md) +* [UI Component List](UIComponents.md) * [Icons](Icons.md) + +## 4. Admin Panel Features + +* [Filters](Filters.md) +* [Export](Export.md) +* [Feed Export](FeedExport.md) * [Notifications](Notifications.md) -* [History-Actions](HistoryActions.md) -## 3. Frontend Integration +* [History Actions](HistoryActions.md) +* [Knowledge Base](KnowledgeBase.md) + * [Writing Articles](KnowledgeBase/WritingArticles.md) +* [AI Assistant](AiAssistant.md) + * [Building Agents](AiAssistant/BuildingAgents.md) + * [Agent Skills](AiAssistant/AgentSkills.md) + * [Admin Links & UI Methods](AiAssistant/AdminLinksAndUiMethods.md) +* [Feedback](Feedback.md) + +## 5. Frontend Integration * [Inertia Adapter & Flash](InertiaAdapter.md) * [Form Error Management](FormError.md) * [Appearance](Appearance.md) -## 4. Admin Panel Features +## 6. Access Control -* [Admin Controls](Controls.md) -* [Navigation Catalog](Navigation.md) +* [Authorization](Authorization.md) +* [Access Rights Tokens](AccessRights/access-rights-tokens.md) +* [Field-Level Restrictions](AccessRights/AccessRightsModelFields.md) +* [User-Owned Records & Memberships](AccessRights/user-owned-records.md) +* [Record Access Cheat Sheet](AccessRights/record-access-cheatsheet.md) -## 5. Access Control +## Other -* [Model-Level Permissions](AccessControl/ModelPermissions.md) -* [Field-Level Restrictions](AccessControl/FieldRestrictions.md) -* [Role & Group Management](AccessControl/RoleGroups.md) -* [Global UI Components](GlobalUI.md) -* [UI Component List](UIComponents.md) +* [Commands](Commands.md) +* [Development](development.md) +* [Internal Queries](InternalQueries.md) +* [Troubleshooting](Troubleshooting.md) diff --git a/docs/login-additional-link.md b/docs/login-additional-link.md index 0147c6b4..29d36d5f 100644 --- a/docs/login-additional-link.md +++ b/docs/login-additional-link.md @@ -23,7 +23,7 @@ auth: { captcha: true, // Shown as a plain link at the bottom of the login card addishinalLoginPage: { - link: '/model/userap/register', // becomes /model/userap/register + link: '/model/User/register', // becomes /model/User/register textKey: 'Additional login page' // optional; defaults to this value } } diff --git a/eslint.config.async.mjs b/eslint.config.async.mjs new file mode 100644 index 00000000..9b25280b --- /dev/null +++ b/eslint.config.async.mjs @@ -0,0 +1,42 @@ +import typescriptEslint from "@typescript-eslint/eslint-plugin"; +import tsParser from "@typescript-eslint/parser"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +/** + * The promise-safety gate, kept separate from the general lint config so it can + * be required to pass on its own. + * + * `AccessRightsHelper.hasPermission` is asynchronous: a forgotten `await` leaves + * a pending promise, which is truthy, so `if (hasPermission(...))` would grant + * access to everyone holding the token regardless of its contextual `check`. + * `no-misused-promises` turns exactly that mistake into an error. + */ +export default [ + { + files: ["src/**/*.ts"], + ignores: ["src/assets/**", "src/ui/**"], + + plugins: { + "@typescript-eslint": typescriptEslint, + }, + + languageOptions: { + parser: tsParser, + parserOptions: { + project: ["./src/tsconfig.json"], + tsconfigRootDir: __dirname, + }, + }, + + rules: { + "@typescript-eslint/no-misused-promises": ["error", { + checksConditionals: true, + checksVoidReturn: false, + checksSpreads: true, + }], + }, + }, +]; diff --git a/eslint.config.mjs b/eslint.config.mjs index d2176d81..b0f0aad6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -63,4 +63,29 @@ export default [ "@typescript-eslint/no-explicit-any": "warn", }, }, + { + // Type-aware rules for the backend sources. `hasPermission` and the rest + // of the access-rights API are asynchronous, and a forgotten `await` + // turns into a truthy pending promise that silently grants access — + // `no-misused-promises` is what makes that a build error instead. + files: ["src/**/*.ts"], + ignores: ["src/assets/**", "src/ui/**"], + + languageOptions: { + parser: tsParser, + parserOptions: { + project: ["./src/tsconfig.json"], + tsconfigRootDir: __dirname, + }, + }, + + rules: { + "@typescript-eslint/no-misused-promises": ["error", { + checksConditionals: true, + checksVoidReturn: false, + checksSpreads: true, + }], + "@typescript-eslint/await-thenable": "warn", + }, + }, ]; diff --git a/fixture/adminizerConfig.ts b/fixture/adminizerConfig.ts index b6ca318c..c873d641 100644 --- a/fixture/adminizerConfig.ts +++ b/fixture/adminizerConfig.ts @@ -1,16 +1,20 @@ import { AdminpanelConfig } from "../dist"; -const routePrefix = "/adminizer"; +export const routePrefix = "/adminizer"; const models: AdminpanelConfig["models"] = { Test: { title: 'Test model', model: 'test', displayName: 'title', - userAccessRelation: 'owner', + // userAccessRelation: 'owner', fields: { - createdAt: false, - updatedAt: false, + createdAt: { + visible: false + }, + updatedAt: { + visible: false + }, title: { title: 'Title', type: 'string', @@ -20,9 +24,9 @@ const models: AdminpanelConfig["models"] = { title: 'Images', type: 'mediamanager', options: { - id: "default", // 'default' is default instance (src/lib/mediamanager/DefaultMediaManager) + id: "default", group: 'banner', - accept: ['image/jpeg, image/png'] + accept: ['image/jpeg', 'image/png'] } }, schema: {} @@ -42,41 +46,49 @@ const models: AdminpanelConfig["models"] = { }, icon: 'receipt' }, - Example: { + full: { title: 'All controls', model: 'example', displayName: 'description', - userAccessRelation: 'owner', - tools: [ - { - id: '1', - link: `https://google.com`, - type: 'blank', - title: 'Some new action', - icon: 'reorder', + filters: { + enabled: true, + excludeFromFilters: ['createdAt', 'id', 'ownerId', 'testRelation', 'testRelationExample'], + }, + // userAccessRelation: 'owner', + // tools: [ + // { + // id: '1', + // link: `https://google.com`, + // type: 'blank', + // title: 'Some new action', + // icon: 'reorder', + // }, + // ], + fields: { + createdAt: { + title: 'created At', + type: 'string', }, - { - id: '2', - link: `${routePrefix}/form/global`, - type: 'self', - title: 'Form example', - icon: 'payment', + updatedAt: { + visible: false }, - { - id: '3', - link: 'https://google.com', - type: 'blank', - title: 'Form example from file Form example from file', - icon: 'touch_app', - } - ], - fields: { - createdAt: false, - updatedAt: false, title: { title: 'Title', type: 'string', - required: true + required: true, + // inlineEditable: false + }, + tui: { + type: 'tuieditor', + options: { + name: 'toast-ui', + config: { + hideModeSwitch: true, + // previewStyle: 'vertical', + initialEditType: 'wysiwyg', + height: '800px' + }, + } }, description: { title: 'Textarea', @@ -86,7 +98,8 @@ const models: AdminpanelConfig["models"] = { }, sort: { type: 'boolean', - title: 'Boolean' + title: 'Boolean', + // inlineEditable: true }, disabled_text: { title: 'Disabled', @@ -100,7 +113,8 @@ const models: AdminpanelConfig["models"] = { options: { min: 10, max: 80 - } + }, + // inlineEditable: true }, select: { title: 'Select', @@ -135,6 +149,7 @@ const models: AdminpanelConfig["models"] = { number: { title: 'Number', type: 'number', + // inlineEditable: true }, color: { title: 'color', @@ -145,16 +160,10 @@ const models: AdminpanelConfig["models"] = { type: 'week', }, json: { - type: 'jsoneditor' - }, - tui: { - type: 'tuieditor', - options: { - name: 'toast-ui', - config: { - hideModeSwitch: true, - previewStyle: 'vertical', - }, + type: 'jsoneditor', + customFilter: { + handlerId: 'Example.json', + label: 'Custom filtering' } }, code: { @@ -173,6 +182,9 @@ const models: AdminpanelConfig["models"] = { datatable: { title: 'Price', type: 'table', + customFilter: { + handlerId: 'Example.datatable' + }, options: { config: { dataSchema: { name: null, footage: null, price: null }, @@ -190,44 +202,41 @@ const models: AdminpanelConfig["models"] = { isIn: ['Sone', 'Stwo', 'Sthree', 'Sfour', 'Sfive'], type: 'select-many' }, - checkboxes: { - title: 'Checkboxes', - isIn: ['one', 'two', 'three'] - }, editor: { title: 'Editor', type: 'wysiwyg', options: { - // name: 'react-quill', - name: 'ckeditor', - config: { - items: [ - // 'sourceEditing', // This is for test, see full list of items in src/lib/controls/wysiwyg/CKeditor.ts - // 'showBlocks', - // '|', - 'heading', - '|', - 'bold', - 'italic', - 'underline', - '|', - // 'horizontalLine', - 'link', - 'insertImage', - 'insertTable', - 'blockQuote', - '|', - 'alignment', - '|', - 'bulletedList', - 'numberedList', - 'outdent', - 'indent', - ] - } + name: 'react-quill', + config: {}, + // name: 'ckeditor', + // config: { + // items: [ + // // 'sourceEditing', // This is for test, see full list of items in src/lib/controls/wysiwyg/CKeditor.ts + // // 'showBlocks', + // // '|', + // 'heading', + // '|', + // 'bold', + // 'italic', + // 'underline', + // '|', + // // 'horizontalLine', + // 'link', + // 'insertImage', + // 'insertTable', + // 'blockQuote', + // '|', + // 'alignment', + // '|', + // 'bulletedList', + // 'numberedList', + // 'outdent', + // 'indent', + // ] + // } } }, - testRelation: { + testRelationExample: { title: 'Test one association', displayModifier: function (data) { return data?.title; @@ -236,30 +245,42 @@ const models: AdminpanelConfig["models"] = { }, tests: { title: 'One to many association', - displayModifier: function (data) { - return data?.title; + displayModifier: function (data: any) { + if (Array.isArray(data)) { + return data + .map((item: any) => item?.title) + .filter(Boolean) + .join(', '); + } + return data?.title || ''; + }, + disabled: false + }, + owner: { + title: 'Owner', + displayModifier: function (data: any) { + return data?.login || ''; }, disabled: false }, }, list: { fields: { - json: false, - tui: false, - geojson: false, - week: false, - color: false, - range: false, - date: false, - month: false, - selectMany: false, - select: false, - dateTime: false, - testRelation: false, - tests: false, - price: false, - code: false, - datatable: false + // json: false, + // tui: false, + // geojson: false, + // week: false, + // color: false, + // range: false, + // date: false, + // month: false, + // selectMany: false, + // select: false, + // testRelation: false, + // tests: false, + // price: false, + // code: false, + // datatable: false }, actions: { global: [ @@ -287,12 +308,6 @@ const models: AdminpanelConfig["models"] = { type: 'blank', title: 'Google', icon: 'insert_link' - }, { - id: "5", - link: `${routePrefix}/form/global`, - type: 'self', - title: 'Form', - icon: 'insert_link' }, ], inline: [ @@ -332,8 +347,12 @@ const models: AdminpanelConfig["models"] = { JsonSchema: { title: 'Json schema', model: 'jsonschema', + // Layout contexts demo: the pages of this model always render in the + // System context (fixture/apps/contexts), whatever the user came from. + context: 'system', navbar: { - groupsAccessRights: ["admins"] + groupsAccessRights: ["admins"], + section: 'Content' }, fields: { data: { @@ -393,6 +412,9 @@ const models: AdminpanelConfig["models"] = { title: 'Категории', model: 'category', icon: 'category', + navbar: { + section: 'Content' + }, displayName: (data: any) => { return data?.slug ?? 'no data' }, @@ -400,12 +422,14 @@ const models: AdminpanelConfig["models"] = { createdAt: { title: 'Created at', }, - updatedAt: false, + updatedAt: { + visible: false + }, mediamanager_one: { title: 'Images 1', type: 'mediamanager', options: { - id: "default", // 'default' is default instance (src/lib/mediamanager/DefaultMediaManager) + id: "default", group: 'banner', accept: ['image/svg+xml'] } @@ -414,7 +438,7 @@ const models: AdminpanelConfig["models"] = { title: 'Images 2', type: 'mediamanager', options: { - id: 'default', // 'default' is default instance (src/lib/mediamanager/DefaultMediaManager) + id: 'default', group: 'avatars', initTab: 'table-application', accept: ['image/jpeg'] @@ -451,13 +475,29 @@ const models: AdminpanelConfig["models"] = { visible: false, }, fields: { - createdAt: false, - updatedAt: false, + createdAt: { + visible: false + }, + updatedAt: { + visible: false + } } - }, + } } const config: AdminpanelConfig = { + list: { + defaultPageSize: 5 + }, + filters: { + enabled: true + }, + system: { + defaultORM: process.env.ORM ?? "sequelize", + internalModelAccess: { + "test-catalog": ["TestCatalog", 'Category'] + } + }, mediamanager: { fileStoragePath: '.tmp/public', allowMIME: ['image/*', 'application/*', 'text/*', 'video/*'], @@ -489,10 +529,13 @@ const config: AdminpanelConfig = { path: 'api/*' }, aiAssistant: { - // enabled: (process.env.ENABLE_AI_ASSISTANT ?? "true") === 'true', - enabled: false, - defaultModel: 'openai-data', - models: ['openai-data', 'dummy'], + enabled: (process.env.ENABLE_AI_ASSISTANT ?? 'true') === 'true', + defaultModel: 'openharness', + models: ['openharness', 'openai-data', 'dummy'], + }, + // Demo knowledge base; the implementation is registered in fixture/index.ts + documentation: { + enabled: true, }, routePrefix: routePrefix, // routePrefix: "/admin", @@ -510,216 +553,34 @@ const config: AdminpanelConfig = { defaultWidgets: [ 'action_one', 'info_one', + 'app_info_widget', + 'app_switcher_widget', + 'app_action_widget', + 'app_link_widget', + 'app_custom_counter', 'siteLinks', 'site_custom', 'site_switcher' ], autoloadWidgetsPath: 'fixture/widgets' }, - navigation: { - items: [ - { - title: 'Category', - model: "Category", - urlPath: '/catalog/category/${data.record.slug}' - }, - { - title: 'Example', - model: "Example", - urlPath: `${routePrefix}/model/Example/\${data.record.id}` - } - ], - // Links in the admin panel leading to different navigation data (for example: header, footer) should end the same way as you specify in the array - // /admin/catalog/navigation/footer - // /admin/catalog/navigation/header - sections: ['header', 'footer'], - groupField: [ - { - name: 'link', - label: 'Ссылка', - required: false - }, - { - name: "test_field", - label: 'Test', - required: false - }, - { - name: "test_feild2", - label: 'Test2', - required: false - } - ], - }, - forms: { - data: { - global: { - field1: { - title: 'Field1', - type: 'string', - value: 'Some string', - required: true, - }, - mediamanager: { - title: 'Images', - type: 'mediamanager', - options: { - id: "default", - group: 'form_global_images', - initTab: "table-application", - accept: ["application/pdf", "application/msword"] - }, - value: null - }, - field2: { - title: 'Field2', - type: 'text', - value: 'Some text', - required: true, - tooltip: 'tooltip for field2', - }, - json: { - title: 'Json', - type: 'jsoneditor' - }, - text: { - title: 'Editor', - type: 'wysiwyg', - }, - tabs_video: { - "title": "Табы видео", - "type": "table", - "tooltip": "Нажмите на таблице правой клавишей мыши, выберите \"Вставить строку ниже\", в добавленную строку вставьте код полученный на сервисе ВКонтакте или YouTube через кнопку \"Поделиться\"", - "options": { - "config": { - "dataSchema": { - "iframe": null - }, - "colHeaders": [ - "Iframe" - ], - "rowHeaders": true, - "columns": [ - { - "data": "iframe" - } - ], - "height": "auto", - "width": "100%", - "manualColumnResize": true, - "contextMenu": true, - "licenseKey": "non-commercial-and-evaluation" - } - }, - "value": null - }, - tabs_tests: { - "title": "Табы онлайн-тесты", - "tooltip": "Нажмите на таблице правой клавишей мыши, выберите \"Вставить строку ниже\", в соответствующих столбцах вставьте ссылку на тест и его название", - "type": "table", - "options": { - "config": { - "dataSchema": { - "link": null, - "text": null - }, - "colHeaders": [ - "Ссылка", - "Текст" - ], - "rowHeaders": true, - "columns": [ - { - "data": "link" - }, - { - "data": "text" - } - ], - "height": "auto", - "width": "100%", - "manualColumnResize": true, - "contextMenu": true, - "licenseKey": "non-commercial-and-evaluation" - } - }, - "value": null - }, - tabs_links: { - "title": "Табы полезные ссылки", - "type": "table", - "tooltip": "Нажмите на таблице правой клавишей мыши, выберите \"Вставить строку ниже\", в соответствующих столбцах вставьте ссылку на сайт и его название", - "options": { - "config": { - "dataSchema": { - "link": null, - "text": null - }, - "colHeaders": [ - "Ссылка", - "Текст" - ], - "rowHeaders": true, - "columns": [ - { - "data": "link" - }, - { - "data": "text" - } - ], - "height": "auto", - "width": "100%", - "manualColumnResize": true, - "contextMenu": true, - "licenseKey": "non-commercial-and-evaluation" - } - }, - "value": null - } - } - } - }, navbar: { additionalLinks: [ - { - id: '1', - link: `${routePrefix}/form/global`, - title: 'Global Settings', - type: 'self', - icon: 'build', - accessRightsToken: 'read-global-form' - }, - { - id: '2', - link: `${routePrefix}/module-test`, - type: 'self', - title: 'Test Module', - icon: '360', - accessRightsToken: 'read-global-form' - }, - { - id: '3', - type: "self", - link: `${routePrefix}/catalog/navigation/header`, - title: 'Nav Header', - icon: 'menu' - }, - { - id: '4', - type: "self", - link: `${routePrefix}/catalog/navigation/footer`, - title: 'Nav Footer', - icon: 'menu' - }, { id: '5', type: "self", link: `${routePrefix}/catalog/test-catalog`, title: 'Test Catalog', - icon: 'bug_report' + icon: 'bug_report', + badge: 3 } - ] + ], + breadcrumbs: 'auto', + sections: { + Platform: { icon: 'dashboard', order: 1 }, + Content: { icon: 'article', order: 2 }, + System: { icon: 'settings', order: 3 } + } }, sections: [ { @@ -743,13 +604,6 @@ const config: AdminpanelConfig = { type: 'blank', icon: 'share' }, - { - id: "2", - title: 'Sub 3 Sub 3 Sub 3 Sub 3', - link: `${routePrefix}/form/global`, - type: 'self', - icon: 'insert_link' - }, { id: "3", title: 'Sub 4', @@ -806,19 +660,13 @@ const config: AdminpanelConfig = { }, translation: { locales: ['en', 'ru', 'de', 'ua'], - //path: 'fixture/locales', // relative path to translations directory + directory: 'fixture/locales', // relative path to translations directory + // missingTranslationDirectory: 'fixture/locales_missing', defaultLocale: 'en' }, models: models, //@ts-ignore generator: {}, - globalSettings: { // Global project settings - enableMigrations: true - }, - migrations: { - path: 'mg_path', // path to migrations - //config: string | object // db-migrate config - }, showVersion: { link: 'https://docs.adminizer.org', hint: 'Adminizer documentation', diff --git a/fixture/apps/ai-assistant/AiAssistantApp.ts b/fixture/apps/ai-assistant/AiAssistantApp.ts new file mode 100644 index 00000000..6fb40827 --- /dev/null +++ b/fixture/apps/ai-assistant/AiAssistantApp.ts @@ -0,0 +1,136 @@ +import { + AbstractAdminizerApp, + Adminizer, + AppSetupContext, +} from "../../../dist"; +import type {AppAiAssistantContext} from "../../../dist"; +import {AiAssistantController} from "../../../dist/controllers/ai/AiAssistantController"; +import {AbstractAiModelService} from "../../../dist/lib/ai-assistant/AbstractAiModelService"; +import {DummyAiModelService} from "./DummyAiModelService"; +import {OpenAiDataAgentService} from "./OpenAiDataAgentService"; +import {OpenAiModelService} from "./OpenAiModelService"; +import {OpenHarnessDataAgentService} from "./OpenHarnessDataAgentService"; + +interface AiAssistantAppConfig { + route: string; + defaultModel?: string; + models: string[]; +} + +interface AiAssistantModelDefinition { + id: string; + name: string; + description: string; + enabled?: () => boolean; + factory: (context: AppAiAssistantContext) => AbstractAiModelService; +} + +const modelDefinitions: Record = { + dummy: { + id: "dummy", + name: "Dummy assistant", + description: "Access to dummy AI assistant model", + factory: () => new DummyAiModelService(), + }, + openai: { + id: "openai", + name: "OpenAI fixture agent", + description: "Access to OpenAI fixture agent", + factory: (context) => new OpenAiModelService(context), + }, + "openai-data": { + id: "openai-data", + name: "OpenAI data explorer", + description: "Access to OpenAI data explorer", + enabled: () => Boolean(process.env.OPENAI_API_KEY ?? process.env.ADMINIZER_OPENAI_KEY), + factory: (context) => new OpenAiDataAgentService(context), + }, + openharness: { + id: "openharness", + name: "OpenHarness data explorer", + description: "Streams answers and queries fixture data through the OpenHarness agent runtime.", + enabled: () => Boolean(process.env.OPENHARNESS_API_KEY || process.env.PROJECT_NAME), + factory: () => new OpenHarnessDataAgentService(), + }, +}; + +export class AiAssistantApp extends AbstractAdminizerApp { + readonly name = "ai-assistant"; + readonly version = "1.0.0"; + declare readonly config: AiAssistantAppConfig; + + constructor(config: Partial = {}) { + super(); + this.config = { + route: "/api/ai-assistant", + defaultModel: "openai-data", + models: ["openharness", "openai-data", "dummy"], + ...config, + }; + } + + setup(ctx: AppSetupContext): void { + const enabledDefinitions = this.getEnabledModelDefinitions(); + const modelIds = enabledDefinitions.map((model) => model.id); + const defaultModel = this.resolveDefaultModel(modelIds); + + for (const model of enabledDefinitions) { + ctx.accessRight({ + id: `ai-assistant-${model.id}`, + name: model.name, + description: model.description, + department: "AI Assistant", + }); + } + + ctx.aiAssistant({ + models: enabledDefinitions.map((model) => model.factory), + }); + + ctx.config({ + aiAssistant: { + enabled: true, + models: modelIds, + ...(defaultModel ? {defaultModel} : {}), + }, + }); + + ctx.controller({ + id: "models", + method: "get", + route: `${this.config.route}/models`, + middleware: AiAssistantController.getModels, + policies: [{type: "auth", mode: "api"}], + }); + + } + + private getEnabledModelDefinitions(): AiAssistantModelDefinition[] { + const enabled: AiAssistantModelDefinition[] = []; + + for (const modelId of this.config.models) { + const definition = modelDefinitions[modelId]; + if (!definition) { + Adminizer.log.warn(`[AiAssistantApp] Unknown AI assistant model "${modelId}". Skipping.`); + continue; + } + + if (definition.enabled && !definition.enabled()) { + Adminizer.log.warn(`[AiAssistantApp] AI assistant model "${modelId}" is disabled by environment. Skipping.`); + continue; + } + + enabled.push(definition); + } + + return enabled.length ? enabled : [modelDefinitions.dummy]; + } + + private resolveDefaultModel(modelIds: string[]): string | undefined { + if (this.config.defaultModel && modelIds.includes(this.config.defaultModel)) { + return this.config.defaultModel; + } + + return modelIds[0]; + } +} diff --git a/fixture/apps/ai-assistant/DummyAiModelService.ts b/fixture/apps/ai-assistant/DummyAiModelService.ts new file mode 100644 index 00000000..ce74d75d --- /dev/null +++ b/fixture/apps/ai-assistant/DummyAiModelService.ts @@ -0,0 +1,151 @@ +import {AbstractAiModelService} from "../../../dist/lib/ai-assistant/AbstractAiModelService"; +import { + AbstractAiConversationHistoryService, + type AiConversation, +} from "../../../dist/lib/ai-assistant/AbstractAiConversationHistoryService"; +import type {AiAssistantMessage} from "../../../dist/interfaces/types"; +import type {User} from "../../../dist/models/User"; + +const textMessage = (role: 'user' | 'assistant', text: string): Record => ({ + role, + content: [{type: 'text', text}], +}); + +class DummyConversationHistoryService extends AbstractAiConversationHistoryService { + private readonly conversations = new Map(); + private readonly activeIds = new Map(); + + constructor(private readonly withDemoConversations: boolean) { + super(); + } + + list(user: User): AiConversation[] { + return [...this.getConversations(user)].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); + } + + getActive(user: User): AiConversation { + const conversations = this.getConversations(user); + const active = conversations.find((conversation) => conversation.id === this.activeIds.get(user.id)); + if (active) return active; + const first = conversations[0] ?? this.create(user); + this.activeIds.set(user.id, first.id); + return first; + } + + create(user: User, title = 'New conversation'): AiConversation { + const now = new Date().toISOString(); + const conversation: AiConversation = { + id: `dummy-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + title, + createdAt: now, + updatedAt: now, + messages: [], + }; + this.getConversations(user).push(conversation); + this.activeIds.set(user.id, conversation.id); + return conversation; + } + + select(user: User, conversationId: string): AiConversation | undefined { + const conversation = this.getConversations(user).find((item) => item.id === conversationId); + if (conversation) this.activeIds.set(user.id, conversation.id); + return conversation; + } + + remove(user: User, conversationId: string): boolean { + const conversations = this.getConversations(user); + const index = conversations.findIndex((conversation) => conversation.id === conversationId); + if (index < 0) return false; + conversations.splice(index, 1); + if (this.activeIds.get(user.id) === conversationId) this.activeIds.delete(user.id); + return true; + } + + saveActive(user: User, messages: Array>, title?: string): AiConversation { + const conversation = this.getActive(user); + conversation.messages = messages; + conversation.updatedAt = new Date().toISOString(); + if (title) conversation.title = title; + else if (conversation.title === 'New conversation') { + const userMessage = messages.find((message) => message.role === 'user'); + const part = Array.isArray(userMessage?.content) + ? userMessage.content.find((item) => (item as {type?: string})?.type === 'text') as {text?: unknown} | undefined + : undefined; + if (typeof part?.text === 'string' && part.text.trim()) { + conversation.title = part.text.trim().slice(0, 80); + } + } + return conversation; + } + + private getConversations(user: User): AiConversation[] { + let conversations = this.conversations.get(user.id); + if (conversations) return conversations; + + conversations = this.withDemoConversations ? this.createDemoConversations() : []; + this.conversations.set(user.id, conversations); + if (conversations[0]) this.activeIds.set(user.id, conversations[0].id); + return conversations; + } + + private createDemoConversations(): AiConversation[] { + const now = new Date().toISOString(); + return [ + { + id: 'dummy-demo-orders', + title: 'Order summary for today', + createdAt: now, + updatedAt: now, + messages: [ + textMessage('user', 'Show a short summary of today’s orders.'), + textMessage('assistant', 'Demo: 24 orders were created today. Total revenue is $1,842.'), + ], + }, + { + id: 'dummy-demo-customers', + title: 'Customers needing attention', + createdAt: now, + updatedAt: now, + messages: [ + textMessage('user', 'Which customers need attention?'), + textMessage('assistant', 'Demo: 3 customers have unresolved support requests and 2 have overdue invoices.'), + ], + }, + { + id: 'dummy-demo-help', + title: 'What can the assistant do?', + createdAt: now, + updatedAt: now, + messages: [ + textMessage('user', 'What can you help me with?'), + textMessage('assistant', 'Demo: I can answer questions, summarize data, and help test the conversation history UI.'), + ], + }, + ]; + } +} + +export class DummyAiModelService extends AbstractAiModelService { + constructor() { + super({ + id: "dummy", + name: "Dummy assistant", + description: "Returns a simple echo response for local testing.", + }, new DummyConversationHistoryService(process.env.DUMMY_AI_DEMO_CONVERSATIONS !== 'false')); + this.registerChatCommand({ + id: 'hello', + description: 'Return a greeting from the dummy agent.', + }); + } + + async generateReply( + prompt: string, + _history: AiAssistantMessage[], + user: User, + ): Promise { + if (prompt.trim().toLowerCase() === '/hello') { + return `Hello ${user.login}, this command was handled by the dummy agent.`; + } + return `Hello ${user.login}, this is a dummy AI response.`; + } +} diff --git a/fixture/helpers/ai/OpenAiDataAgentService.ts b/fixture/apps/ai-assistant/OpenAiDataAgentService.ts similarity index 85% rename from fixture/helpers/ai/OpenAiDataAgentService.ts rename to fixture/apps/ai-assistant/OpenAiDataAgentService.ts index 34564d6e..79b278f6 100644 --- a/fixture/helpers/ai/OpenAiDataAgentService.ts +++ b/fixture/apps/ai-assistant/OpenAiDataAgentService.ts @@ -8,22 +8,23 @@ import { RunContext, } from '@openai/agents'; import {AbstractAiModelService} from '../../../dist/lib/ai-assistant/AbstractAiModelService'; -import {AiAssistantMessage, Entity} from '../../../dist/interfaces/types'; -import {ModelConfig} from '../../../dist/interfaces/adminpanelConfig'; +import type {AiAssistantMessage, ModelResource} from '../../../dist/interfaces/types'; +import type {ModelConfig} from '../../../dist/interfaces/adminpanelConfig'; import {Adminizer} from '../../../dist/lib/Adminizer'; -import {DataAccessor} from '../../../dist/lib/DataAccessor'; -import {UserAP} from '../../../dist/models/UserAP'; +import type {AppAiAssistantContext} from '../../../dist/lib/app-manager/AdminizerApp'; +import type {DataAccessor} from '../../../dist/lib/DataAccessor'; +import type {User} from '../../../dist/models/User'; interface AgentContext { - user: UserAP; + user: User; } export class OpenAiDataAgentService extends AbstractAiModelService { private readonly apiKey?: string; private readonly model: string; - constructor(adminizer: Adminizer) { - super(adminizer, { + constructor(private readonly context: AppAiAssistantContext) { + super({ id: 'openai-data', name: 'OpenAI data explorer', description: 'Answers questions with live data retrieved via Adminizer models.', @@ -46,7 +47,7 @@ export class OpenAiDataAgentService extends AbstractAiModelService { public async generateReply( prompt: string, history: AiAssistantMessage[], - user: UserAP, + user: User, ): Promise { if (!this.isEnabled()) { return 'The OpenAI data agent is not configured. Please set the OPENAI_API_KEY environment variable.'; @@ -57,7 +58,7 @@ export class OpenAiDataAgentService extends AbstractAiModelService { console.log('📝 [Agent Debug] User prompt:', prompt); console.log('📚 [Agent Debug] History length:', history.length); - const agent = this.createAgent(user); + const agent = await this.createAgent(user); const conversation = this.toAgentInput(history); console.log('🚀 [Agent Debug] Running agent with maxTurns: 6'); @@ -80,8 +81,8 @@ export class OpenAiDataAgentService extends AbstractAiModelService { } } - private createAgent(user: UserAP): Agent { - const accessibleModels = this.listReadableModels(user); + private async createAgent(user: User): Promise> { + const accessibleModels = await this.listReadableModels(user); const modelSummary = accessibleModels.length > 0 ? accessibleModels.map(({name, config}) => `• ${name} (model key: ${config.model})`).join('\n') : 'No models are currently accessible.'; @@ -137,7 +138,7 @@ export class OpenAiDataAgentService extends AbstractAiModelService { throw new Error(`Model "${input.model}" is not registered in Adminizer.`); } - const accessor = new DataAccessor(this.adminizer, activeUser, entity, 'list'); + const accessor = this.context.createDataAccessor(entity, activeUser, 'list'); let criteria = {}; // Handle filter parameter - can be empty string (default), undefined, or actual filter @@ -163,7 +164,7 @@ export class OpenAiDataAgentService extends AbstractAiModelService { // Handle fields parameter - can be empty array (default), undefined, or actual fields const fieldsValue = input.fields; const projected = fieldsValue && Array.isArray(fieldsValue) && fieldsValue.length > 0 - ? limited.map((record) => this.pickFields(record, fieldsValue)) + ? limited.map((record: Record) => this.pickFields(record, fieldsValue)) : limited; if (fieldsValue && fieldsValue.length > 0) { @@ -220,7 +221,7 @@ export class OpenAiDataAgentService extends AbstractAiModelService { throw new Error(`Model "${input.model}" is not registered in Adminizer.`); } - const accessor = new DataAccessor(this.adminizer, activeUser, entity, 'add'); + const accessor = this.context.createDataAccessor(entity, activeUser, 'add'); console.log('🔐 [Tool] Preparing create payload with permission checks...'); const sanitizedPayload = await this.prepareCreatePayload(input.payload, accessor); console.log('✅ [Tool] Sanitized payload:', JSON.stringify(sanitizedPayload, null, 2)); @@ -283,7 +284,7 @@ export class OpenAiDataAgentService extends AbstractAiModelService { throw new Error(`Model "${input.model}" is not registered in Adminizer.`); } - const accessor = new DataAccessor(this.adminizer, activeUser, entity, 'edit'); + const accessor = this.context.createDataAccessor(entity, activeUser, 'edit'); console.log('🔐 [Tool] Preparing update payload with permission checks...'); const sanitizedPayload = await this.prepareUpdatePayload(input.payload, accessor); console.log('✅ [Tool] Sanitized payload:', JSON.stringify(sanitizedPayload, null, 2)); @@ -461,75 +462,33 @@ export class OpenAiDataAgentService extends AbstractAiModelService { }); } - private listReadableModels(user: UserAP): Array<{name: string; config: ModelConfig}> { + private async listReadableModels(user: User): Promise> { const readable: Array<{name: string; config: ModelConfig}> = []; - for (const [entityName, rawConfig] of Object.entries(this.adminizer.config.models ?? {})) { - const config = this.ensureModelConfig(entityName, rawConfig); - const modelInstance = config.model - ? this.adminizer.modelHandler.model.get(config.model.toLowerCase()) - : undefined; - - if (!modelInstance) { + for (const resource of this.context.getModelResources()) { + if (!resource.model) { continue; } - const token = `read-${modelInstance.modelname}-model`; - if (this.adminizer.accessRightsHelper.hasPermission(token, user)) { - readable.push({name: entityName, config}); + const token = `read-${resource.model.modelname}-model`; + if (await this.context.checkPermission(token, user)) { + readable.push({name: resource.name, config: resource.config}); } } return readable; } - private resolveEntity(modelName: string): Entity { - const normalized = modelName.trim().toLowerCase(); - const models = this.adminizer.config.models ?? {}; - - for (const [entityName, rawConfig] of Object.entries(models)) { - const config = this.ensureModelConfig(entityName, rawConfig); - const candidateNames = new Set([ - entityName.toLowerCase(), - config.model?.toLowerCase(), - ]); - - if (!candidateNames.has(normalized)) { - continue; - } - - const modelInstance = this.adminizer.modelHandler.model.get(config.model?.toLowerCase()); - if (!modelInstance) { - throw new Error(`Model adapter is not initialized for "${config.model}".`); - } - - return { - name: entityName, - config, - model: modelInstance, - type: 'model', - uri: `${this.adminizer.config.routePrefix}/model/${entityName}`, - }; + private resolveEntity(modelName: string): ModelResource { + const resource = this.context.resolveModelResource(modelName.trim()); + if (!resource) { + throw new Error(`Unknown model "${modelName}".`); } - throw new Error(`Unknown model "${modelName}".`); - } - - private ensureModelConfig(entityName: string, config: ModelConfig | boolean): ModelConfig { - if (typeof config === 'boolean') { - const modelId = entityName.toLowerCase(); - return { - model: modelId, - title: entityName, - icon: 'description', - list: true, - add: true, - edit: true, - remove: true, - view: true, - } as ModelConfig; + if (!resource.model) { + throw new Error(`Model adapter is not initialized for "${resource.config.model}".`); } - return config; + return resource; } } diff --git a/fixture/apps/ai-assistant/OpenAiModelService.ts b/fixture/apps/ai-assistant/OpenAiModelService.ts new file mode 100644 index 00000000..c09201a7 --- /dev/null +++ b/fixture/apps/ai-assistant/OpenAiModelService.ts @@ -0,0 +1,175 @@ +import {Adminizer} from "../../../dist"; +import {AbstractAiModelService} from "../../../dist/lib/ai-assistant/AbstractAiModelService"; +import type {AppAiAssistantContext} from "../../../dist/lib/app-manager/AdminizerApp"; +import type {ActionType} from "../../../dist/interfaces/adminpanelConfig"; +import type {AiAssistantMessage, ModelResource} from "../../../dist/interfaces/types"; +import type {DataAccessor} from "../../../dist/lib/DataAccessor"; +import type {User} from "../../../dist/models/User"; + +type AgentAction = "create" | "list" | "update" | "delete"; + +interface AgentInstruction { + action: AgentAction; + modelResource: string; + payload?: Record; + criteria?: Record; +} + +type DataAccessorFactory = (modelResource: ModelResource, user: User, action: ActionType) => DataAccessor; + +const ACTION_TOKENS: Record = { + add: "create", + edit: "update", + list: "read", + view: "read", + remove: "delete", +}; + +export class OpenAiModelService extends AbstractAiModelService { + private readonly createAccessor: DataAccessorFactory; + + constructor( + private readonly context: AppAiAssistantContext, + accessorFactory?: DataAccessorFactory, + ) { + super({ + id: "openai", + name: "OpenAI fixture agent", + description: "Executes structured commands with DataAccessor using the current user permissions.", + }); + this.createAccessor = accessorFactory ?? context.createDataAccessor; + } + + public async generateReply(prompt: string, _history: AiAssistantMessage[], user: User): Promise { + const instruction = this.parseInstruction(prompt); + + if (!instruction) { + return this.usageMessage(); + } + + switch (instruction.action) { + case "create": + return this.handleCreate(instruction, user); + default: + return `Unsupported action "${instruction.action}". The OpenAI agent currently supports only the "create" action.`; + } + } + + private async handleCreate(instruction: AgentInstruction, user: User): Promise { + const modelResource = this.context.resolveModelResource(instruction.modelResource); + if (!modelResource?.model) { + return `Model "${instruction.modelResource}" is not available in this project.`; + } + + if (!await this.userHasPermission(modelResource, user, "add")) { + return `User "${user.login}" does not have permission to create ${modelResource.name} records.`; + } + + if (!instruction.payload || typeof instruction.payload !== "object") { + return 'The "create" action requires a "data" object with field values.'; + } + + try { + const accessor = this.createAccessor(modelResource, user, "add"); + const created = await modelResource.model.create(instruction.payload as any, accessor); + const preview = JSON.stringify(created, null, 2); + const recordId = this.extractPrimaryKey(created, modelResource); + const idMessage = recordId !== undefined ? ` (id: ${recordId})` : ""; + return `Record created in ${modelResource.name}${idMessage}:\n${preview}`; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + Adminizer.log.error("OpenAI agent failed to create record", error); + return `Failed to create ${modelResource.name} record: ${message}`; + } + } + + private parseInstruction(prompt: string): AgentInstruction | null { + try { + const parsed = JSON.parse(prompt); + if (!parsed || typeof parsed !== "object") { + return null; + } + + const rawAction = this.extractString(parsed, ["action", "type"]); + if (!rawAction) { + return null; + } + + const action = rawAction.toLowerCase(); + if (!["create", "list", "update", "delete"].includes(action)) { + return null; + } + + const modelResource = this.extractString(parsed, ["modelResource", "model"]); + if (!modelResource) { + return null; + } + + const payload = this.extractObject(parsed, ["data", "payload", "record"]); + const criteria = this.extractObject(parsed, ["criteria", "where", "filter"]); + + return { + action: action as AgentAction, + modelResource, + payload, + criteria, + }; + } catch { + return null; + } + } + + private usageMessage(): string { + return [ + "Provide JSON instructions so the OpenAI agent can execute them with your permissions.", + "Example:", + "```json", + "{", + ' "action": "create",', + ' "modelResource": "Example",', + ' "data": { "title": "Hello from the agent" }', + "}", + "```", + "The agent uses DataAccessor, so any field-level restrictions from your account are respected.", + ].join("\n"); + } + + private async userHasPermission(modelResource: ModelResource, user: User, action: ActionType): Promise { + const token = this.getPermissionToken(modelResource, action); + return await this.context.checkPermission(token, user); + } + + private getPermissionToken(modelResource: ModelResource, action: ActionType): string { + const verb = ACTION_TOKENS[action]; + const modelName = modelResource.model?.modelname ?? modelResource.config?.model ?? modelResource.name; + return `${verb}-${modelName}-model`.toLowerCase(); + } + + private extractPrimaryKey(record: Partial>, modelResource: ModelResource): unknown { + if (!record) { + return undefined; + } + const primaryKey = (modelResource.model as any)?.primaryKey ?? "id"; + return (record as Record)[primaryKey]; + } + + private extractString(source: any, keys: string[]): string | undefined { + for (const key of keys) { + const value = source?.[key]; + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + } + return undefined; + } + + private extractObject(source: any, keys: string[]): Record | undefined { + for (const key of keys) { + const value = source?.[key]; + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record; + } + } + return undefined; + } +} diff --git a/fixture/apps/ai-assistant/OpenHarnessConnectionManager.ts b/fixture/apps/ai-assistant/OpenHarnessConnectionManager.ts new file mode 100644 index 00000000..ecdf264e --- /dev/null +++ b/fixture/apps/ai-assistant/OpenHarnessConnectionManager.ts @@ -0,0 +1,158 @@ +import {mkdir, readFile, writeFile} from 'node:fs/promises'; +import path from 'node:path'; + +const DEFAULT_BASE_URL = 'https://lllm.m42.cx/v1'; +const IDLE_RECHECK_MS = 60_000; +const NETWORK_RETRY_MS = 60_000; +const HARD_ERROR_RETRY_MS = 5 * 60_000; +const EXPIRY_SLACK_MS = 60_000; + +export type OpenHarnessCredentials = { + apiKey: string; + baseUrl: string; + source: 'env' | 'broker'; + userId: string | null; +}; + +type BrokerState = { + apiKey?: string; + userId?: string; + baseUrl?: string; + expiresAt?: string; +}; + +/** + * Fixture counterpart of RestoApp's connection manager. It retains the broker + * flow and retry behaviour, but stores its state locally because fixture has + * no Settings model. Environment is deliberately its only configuration API. + */ +export class OpenHarnessConnectionManager { + private credentials: OpenHarnessCredentials | null = null; + private state: 'ready' | 'registering' | 'waiting_retry' | 'setup_required' | 'error' = 'registering'; + private lastError: string | null = null; + private nextAttemptAt: number | null = null; + private expiresAt: string | null = null; + private timer: NodeJS.Timeout | null = null; + private resolving: Promise | null = null; + private readonly listeners: Array<() => void> = []; + private readonly statePath = process.env.OPENHARNESS_BROKER_STATE_FILE + ?? path.resolve(process.cwd(), '.tmp/openharness-fixture/broker-state.json'); + + public onChange(listener: () => void): void { this.listeners.push(listener); } + public getCredentialsSync(): OpenHarnessCredentials | null { return this.credentials; } + public isReady(): boolean { return this.state === 'ready' && this.credentials !== null; } + + public getStatus() { + return { + state: this.state, + provider: 'litellm', + baseUrl: this.credentials?.baseUrl ?? this.baseUrl, + source: this.credentials?.source ?? null, + nextAttemptAt: this.nextAttemptAt ? new Date(this.nextAttemptAt).toISOString() : null, + lastError: this.lastError, + expiresAt: this.expiresAt, + }; + } + + public async refresh() { + if (!this.resolving) this.resolving = this.resolve().finally(() => { this.resolving = null; }); + await this.resolving; + return this.getStatus(); + } + + public async reportAuthFailure(): Promise { + if (this.credentials?.source !== 'broker') return; + await this.saveState({}); + this.credentials = null; + this.state = 'registering'; + this.schedule(0); + } + + private get baseUrl(): string { return (process.env.OPENHARNESS_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, ''); } + private get projectName(): string { return (process.env.PROJECT_NAME || '').trim(); } + private get frontendId(): string { return (process.env.OPENHARNESS_FRONTEND_ID || `adminizer-fixture:${this.projectName}`).trim(); } + private get models(): string[] { return (process.env.OPENHARNESS_REGISTER_MODELS || process.env.OPENHARNESS_MODEL || 'grs-base-regular').split(',').map((value) => value.trim()).filter(Boolean); } + + private async resolve(): Promise { + const previousKey = this.credentials?.apiKey ?? null; + const envKey = (process.env.OPENHARNESS_API_KEY || '').trim(); + if (envKey) { + this.setReady({apiKey: envKey, baseUrl: this.baseUrl, source: 'env', userId: null}, previousKey); + return; + } + + const stored = await this.readState(); + if (stored.apiKey && !this.expired(stored.expiresAt)) { + this.expiresAt = stored.expiresAt ?? null; + this.setReady({apiKey: stored.apiKey, baseUrl: stored.baseUrl || this.baseUrl, source: 'broker', userId: stored.userId ?? null}, previousKey); + if (stored.expiresAt) this.schedule(Math.max(Date.parse(stored.expiresAt) - EXPIRY_SLACK_MS - Date.now(), IDLE_RECHECK_MS), true); + return; + } + + this.credentials = null; + this.expiresAt = null; + if (!this.projectName) { + this.state = 'setup_required'; + this.lastError = 'PROJECT_NAME is not set. Set it to register the OpenHarness fixture agent.'; + this.schedule(IDLE_RECHECK_MS, true); + return; + } + await this.register(previousKey); + } + + private async register(previousKey: string | null): Promise { + if (this.nextAttemptAt && this.nextAttemptAt - Date.now() > 1_000) { this.state = 'waiting_retry'; return; } + this.state = 'registering'; + try { + const headers: Record = {'Content-Type': 'application/json', Accept: 'application/json'}; + const brokerToken = (process.env.OPENHARNESS_BROKER_TOKEN || '').trim(); + if (brokerToken) headers.Authorization = `Bearer ${brokerToken}`; + const response = await fetch(`${this.baseUrl}/frontend/register`, { + method: 'POST', headers, + body: JSON.stringify({frontend_id: this.frontendId, project_name: this.projectName, models: this.models}), + }); + const body: any = await response.json().catch(() => ({})); + if (response.ok && body?.api_key) { + const state: BrokerState = {apiKey: body.api_key, userId: body.user_id, baseUrl: body.base_url || this.baseUrl, expiresAt: body.expires_at}; + await this.saveState(state); + this.expiresAt = state.expiresAt ?? null; + this.setReady({apiKey: state.apiKey!, baseUrl: state.baseUrl!, source: 'broker', userId: state.userId ?? null}, previousKey); + return; + } + if (response.status === 429) { + const retryAt = Date.parse(body?.details?.retry_after_at ?? ''); + const retrySeconds = Number(body?.details?.retry_after_seconds); + const delay = Number.isFinite(retryAt) ? Math.max(retryAt - Date.now(), 5_000) : Number.isFinite(retrySeconds) ? Math.max(retrySeconds * 1_000, 5_000) : NETWORK_RETRY_MS; + this.state = 'waiting_retry'; this.lastError = body?.message || 'Broker key issue rate limited'; this.schedule(delay); return; + } + this.state = 'error'; this.lastError = body?.message || body?.error || `Broker registration failed with HTTP ${response.status}`; this.schedule(HARD_ERROR_RETRY_MS); + } catch (error: any) { + this.state = 'error'; this.lastError = error?.message || 'Broker is unreachable'; this.schedule(NETWORK_RETRY_MS); + } + } + + private setReady(credentials: OpenHarnessCredentials, previousKey: string | null): void { + this.credentials = credentials; this.state = 'ready'; this.lastError = null; this.nextAttemptAt = null; + if (credentials.source !== 'broker') this.expiresAt = null; + if (previousKey !== credentials.apiKey) this.listeners.forEach((listener) => listener()); + } + + private schedule(delay: number, silent = false): void { + if (this.timer) clearTimeout(this.timer); + this.nextAttemptAt = silent ? this.nextAttemptAt : Date.now() + delay; + this.timer = setTimeout(() => { this.nextAttemptAt = null; void this.refresh(); }, Math.max(delay, 1_000)); + this.timer.unref?.(); + } + + private async readState(): Promise { + try { return JSON.parse(await readFile(this.statePath, 'utf8')) as BrokerState; } catch { return {}; } + } + private async saveState(state: BrokerState): Promise { + await mkdir(path.dirname(this.statePath), {recursive: true}); + await writeFile(this.statePath, JSON.stringify(state), {mode: 0o600}); + } + private expired(expiresAt?: string): boolean { + const timestamp = Date.parse(expiresAt ?? ''); + return Number.isFinite(timestamp) && timestamp - EXPIRY_SLACK_MS <= Date.now(); + } +} diff --git a/fixture/apps/ai-assistant/OpenHarnessDataAgentService.ts b/fixture/apps/ai-assistant/OpenHarnessDataAgentService.ts new file mode 100644 index 00000000..ab072dfa --- /dev/null +++ b/fixture/apps/ai-assistant/OpenHarnessDataAgentService.ts @@ -0,0 +1,377 @@ +import {z} from 'zod'; +import {existsSync, readFileSync} from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {AbstractAiModelService} from '../../../dist/lib/ai-assistant/AbstractAiModelService'; +import {toJsonSafe} from '../../../dist/lib/ai-assistant/jsonSafe'; +import type { + AiAgentModelOption, + AiAgentPublish, + AiAgentSavedFile, + AiAgentSessionMeta, + AiAgentUiHints, + AiAgentUploadedFile, + AiAssistantMessage, +} from '../../../dist/interfaces/types'; +import type {User} from '../../../dist/models/User'; +import {OpenHarnessConnectionManager} from './OpenHarnessConnectionManager'; + +const DEFAULT_BASE_URL = 'https://lllm.m42.cx/v1'; +const DEFAULT_CONTEXT_WINDOW = 128_000; +const MAX_INLINE_TEXT_CHARS = 256_000; +const MODEL_CATALOG_TTL_MS = 3 * 60_000; +const PROMPTS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'prompts'); + +const BROKER_WAITING_DESCRIPTION = 'The assistant registers itself with the LLM gateway. The broker issues at most one key per hour, so this can take a while — the page keeps retrying automatically.'; +const BROKER_ERROR_DESCRIPTION = 'The assistant registers itself with the LLM gateway. The page will retry automatically.'; +const OPENHARNESS_RU: Record = { + 'Connecting the assistant…': 'Подключение ассистента…', + 'Waiting for the next registration slot': 'Ожидание следующего окна регистрации', + 'Next attempt at': 'Следующая попытка в', + 'Server setup required': 'Требуется завершить настройку сервера', + 'Set the PROJECT_NAME setting to finish the server setup — the assistant needs it to register.': 'Заполните настройку PROJECT_NAME, чтобы завершить настройку сервера — она нужна ассистенту для регистрации.', + 'Registration attempt failed': 'Попытка регистрации не удалась', + [BROKER_WAITING_DESCRIPTION]: 'Ассистент регистрируется на LLM-шлюзе. Брокер выдаёт не более одного ключа в час, поэтому это может занять время — страница повторяет попытки автоматически.', + [BROKER_ERROR_DESCRIPTION]: 'Ассистент регистрируется на LLM-шлюзе. Страница повторит попытку автоматически.', +}; + +const openHarnessText = (locale: string | undefined, text: string): string => + locale?.toLowerCase().split(/[-_]/)[0] === 'ru' ? OPENHARNESS_RU[text] ?? text : text; + +type ModelChoice = AiAgentModelOption & {model: string; baseURL: string}; +type PromptValues = Record; + +function normalizePromptKey(value: string): string { + return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); +} + +function promptPath(name: string): string { + return path.join(PROMPTS_DIR, `${name}.txt`); +} + +function promptExists(name: string): boolean { + return existsSync(promptPath(name)); +} + +function loadSystemPrompt(names: string | string[], values: PromptValues): string { + const templates = ['common', ...(Array.isArray(names) ? names : [names])] + .filter((name, index) => index === 0 || promptExists(name)) + .map((name) => readFileSync(promptPath(name), 'utf8').trim()); + return templates.join('\n\n').replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_match, key: string) => { + const value = values[key]; + return value === undefined || value === null ? '' : String(value); + }).replace(/\n{3,}/g, '\n\n').trim(); +} + +/** + * The fixture version of RestoApp's OpenHarness service. + * + * It intentionally uses only environment credentials: broker registration and + * project settings belong to the host application, while this fixture proves + * that an independently registered plugin can use the Adminizer agent panel. + */ +export class OpenHarnessDataAgentService extends AbstractAiModelService { + private readonly defaultModel = process.env.OPENHARNESS_MODEL?.trim() ?? ''; + private readonly contextWindow = Number(process.env.OPENHARNESS_CONTEXT_WINDOW) || DEFAULT_CONTEXT_WINDOW; + private readonly vision = process.env.OPENHARNESS_VISION === 'true'; + private readonly sessions = new Map(); + private readonly selectedModels = new Map(); + private readonly connection = new OpenHarnessConnectionManager(); + private modelCatalogCache: {fetchedAt: number; models: ModelChoice[]} | null = null; + + constructor() { + super({ + id: 'openharness', + name: 'OpenHarness data explorer', + description: 'Streams answers and reads only fixture data permitted for the current user.', + }); + this.connection.onChange(() => { + this.sessions.clear(); + this.modelCatalogCache = null; + }); + } + + public async generateReply(prompt: string, _history: AiAssistantMessage[], user: User): Promise { + let reply = ''; + await this.streamReply(prompt, user, (event) => { + if (event.type === 'text.delta' && typeof event.text === 'string') reply += event.text; + }); + return reply || 'OpenHarness finished without returning a message.'; + } + + public async streamReply( + input: string | Array>, + user: User, + publish: AiAgentPublish, + ): Promise { + if (!this.connection.isReady()) throw new Error('OpenHarness is not connected to an LLM endpoint yet.'); + const session = await this.getSession(user, publish); + // MCP servers and subagent tools also write into this history, so a + // session that already holds a non-JSON tool result is repaired here + // instead of failing prompt validation for the rest of its life. + session.messages = toJsonSafe(session.messages ?? []); + for await (const event of session.send(input)) publish(event as any); + } + + public async getConnectionStatus() { + return this.connection.refresh(); + } + + public getUiHints(locale?: string): AiAgentUiHints { + const text = (value: string) => openHarnessText(locale, value); + return { + title: this.name, + welcomeHint: 'Ask about the data available to your account.', + composerPlaceholder: 'Ask about your data… type / for commands', + suggestions: ['What can you do? List the data models, admin pages and tools available to me.'], + connectionScreens: { + registering: { + title: text('Connecting the assistant…'), + description: text(BROKER_WAITING_DESCRIPTION), + icon: 'spinner', + }, + waiting_retry: { + title: text('Waiting for the next registration slot'), + description: text(BROKER_WAITING_DESCRIPTION), + icon: 'spinner', + details: [ + {field: 'nextAttemptAt', label: text('Next attempt at'), format: 'local-time'}, + {field: 'lastError', tone: 'error'}, + ], + }, + setup_required: { + title: text('Server setup required'), + description: text('Set the PROJECT_NAME setting to finish the server setup — the assistant needs it to register.'), + icon: 'bot', + }, + error: { + title: text('Registration attempt failed'), + description: text(BROKER_ERROR_DESCRIPTION), + icon: 'error', + details: [ + {field: 'nextAttemptAt', label: text('Next attempt at'), format: 'local-time'}, + {field: 'lastError', tone: 'error'}, + ], + }, + }, + }; + } + + public getSessionMeta(user: User): AiAgentSessionMeta { + const session = this.sessions.get(user.id); + return { + model: this.getCurrentModel(user), + contextWindow: this.contextWindow, + vision: this.vision, + turns: session?.messages.filter((message: Record) => message.role === 'user').length ?? 0, + contextTokens: session ? Math.round(JSON.stringify(session.messages ?? []).length / 4) : 0, + totalUsage: session?.totalUsage ?? {}, + }; + } + + public getSessionHistory(user: User): Array> { + return this.sessions.get(user.id)?.messages ?? []; + } + + public async restoreSessionHistory(user: User, messages: Array>): Promise { + const session = await this.getSession(user); + session.messages = messages; + } + + public resetSession(user: User): boolean { + return this.sessions.delete(user.id); + } + + public async compactSession(user: User): Promise> { + const session = this.sessions.get(user.id); + if (!session?.messages?.length) return {compacted: false}; + let tokensBefore = 0; + let tokensAfter = 0; + let messagesRemoved = 0; + for await (const event of session.compact()) { + if (event.type === 'compaction.start') tokensBefore = event.tokensBefore ?? 0; + if (event.type === 'compaction.pruned') messagesRemoved += event.messagesRemoved ?? 0; + if (event.type === 'compaction.done') tokensAfter = event.tokensAfter ?? 0; + } + return {compacted: true, tokensBefore, tokensAfter, messagesRemoved}; + } + + public async getModelChoices(): Promise { + return this.getAvailableModelChoices(); + } + + public async setCurrentModel(user: User, model: string): Promise { + const normalized = model.trim(); + const available = await this.getAvailableModelChoices(); + if (!normalized || !available.some((choice) => choice.id === normalized)) { + throw new Error(`Model "${model}" is not available in this fixture.`); + } + if (this.getCurrentModel(user) === normalized) return false; + this.selectedModels.set(user.id, normalized); + this.resetSession(user); + return true; + } + + public async saveUploadedFiles(_user: User, files: AiAgentUploadedFile[]): Promise { + return files.map((file, index) => ({ + id: `fixture-file-${Date.now()}-${index}`, + name: file.originalname || 'attachment', + mimeType: file.mimetype || 'application/octet-stream', + size: file.size ?? file.buffer.length, + })); + } + + private getCurrentModel(user: User): string { + return this.getCurrentModelChoice(user).id; + } + + private getCurrentModelChoice(user: User): ModelChoice { + const selected = this.selectedModels.get(user.id); + const cached = this.modelCatalogCache?.models ?? []; + if (selected) { + const choice = cached.find((entry) => entry.id === selected); + if (choice) return choice; + } + if (this.defaultModel) { + const choice = cached.find((entry) => entry.id === this.defaultModel); + if (choice) return choice; + } + return cached[0] ?? this.getFallbackModelChoice(); + } + + private getFallbackModelChoice(): ModelChoice { + const model = this.defaultModel || 'default'; + return { + id: model, + model, + baseURL: this.connection.getCredentialsSync()?.baseUrl ?? DEFAULT_BASE_URL, + contextWindow: this.contextWindow, + maxOutputTokens: null, + vision: this.vision, + costCoefficient: null, + }; + } + + private getModelPromptName(modelId: string): string { + const candidate = `openharness-model-${normalizePromptKey(modelId)}`; + return promptExists(candidate) ? candidate : 'openharness-model-default'; + } + + /** + * LiteLLM scopes the OpenAI-compatible model list to the caller's key. + * Keep the catalogue short-lived so a broker key change or permission + * update is reflected without making every metadata request hit the proxy. + */ + private async getAvailableModelChoices(): Promise { + const cached = this.modelCatalogCache; + if (cached && Date.now() - cached.fetchedAt < MODEL_CATALOG_TTL_MS) return cached.models; + + const credentials = this.connection.getCredentialsSync(); + if (!credentials) return [this.getFallbackModelChoice()]; + + try { + const baseURL = credentials.baseUrl.replace(/\/+$/, ''); + const response = await fetch(`${baseURL}/models`, { + headers: {Accept: 'application/json', Authorization: `Bearer ${credentials.apiKey}`}, + }); + if (!response.ok) throw new Error(`Model catalogue request failed with HTTP ${response.status}`); + const body = await response.json() as { + data?: Array<{id?: string; max_input_tokens?: number | null; max_output_tokens?: number | null}>; + }; + const models = Array.from(new Map((body.data ?? []).flatMap((entry): Array<[string, ModelChoice]> => { + const id = entry.id?.trim(); + if (!id) return []; + return [[id, { + id, + model: id, + baseURL, + contextWindow: Number.isFinite(entry.max_input_tokens) ? entry.max_input_tokens ?? null : null, + maxOutputTokens: Number.isFinite(entry.max_output_tokens) ? entry.max_output_tokens ?? null : null, + vision: this.vision, + costCoefficient: null, + }]]; + })).values()); + if (!models.length) throw new Error('Model catalogue is empty'); + this.modelCatalogCache = {fetchedAt: Date.now(), models}; + return models; + } catch { + return [this.getFallbackModelChoice()]; + } + } + + private async getSession(user: User, publish?: AiAgentPublish): Promise { + const credentials = this.connection.getCredentialsSync(); + if (!credentials) throw new Error('OpenHarness is not connected to an LLM endpoint yet.'); + const existing = this.sessions.get(user.id); + const fingerprint = `${credentials.baseUrl}:${credentials.apiKey}`; + if (existing?.__openharnessFingerprint === fingerprint) { + if (publish) existing.__openharnessPublish = publish; + return existing; + } + + const load = (name: string): Promise => Function('name', 'return import(name)')(name); + const [{Agent, Session}, {createOpenAI}, {tool, jsonSchema}] = await Promise.all([ + load('@openharness/core'), load('@ai-sdk/openai'), load('ai'), + ]); + const choices = await this.getAvailableModelChoices(); + const choice = choices.find((entry) => entry.id === this.getCurrentModel(user)) ?? choices[0]; + const provider = createOpenAI({apiKey: credentials.apiKey, baseURL: credentials.baseUrl}); + + const searchAdminLinks = tool({ + description: 'Search admin pages visible to the current user. Returns concrete links and link' + + ' templates: a template has params and is opened by passing its id plus values.', + inputSchema: z.object({query: z.string().min(1)}), + execute: async ({query}: {query: string}) => ({links: await this.searchAdminLinks(user, query)}), + }); + + let session: any; + const navigateAdmin = tool({ + description: 'Open an admin page in the user\'s browser tab. Pass href for a concrete link, or' + + ' template plus params (e.g. {"id": "…"}) for a record page returned by search_admin_links.', + inputSchema: z.object({ + href: z.string().min(1).optional(), + template: z.string().min(1).optional(), + params: z.record(z.string(), z.string()).optional(), + }), + execute: async (input: {href?: string; template?: string; params?: Record}) => { + if (!session?.__openharnessPublish) { + throw new Error('Navigation is available only during an active assistant response.'); + } + return {opened: await this.openAdminLink(input, user, session.__openharnessPublish)}; + }, + }); + + // Adminizer skills are provider-neutral (JSON Schema + an execute + // callback), so their schema is handed to the model as-is. Built-in + // data skills are already scoped to this user's permissions. + const skills = await this.getAgentSkills(user); + const skillTools = Object.fromEntries(skills.map((skill) => [skill.id, tool({ + description: skill.description, + inputSchema: jsonSchema(skill.inputSchema), + execute: async (input: Record) => this.executeAgentSkill(skill.id, input, user), + })])); + const availableTools = [...Object.keys(skillTools), 'search_admin_links', 'navigate_admin'].sort().join(', '); + + const agent = new Agent({ + name: this.name, + model: provider.chat(choice.model), + instructions: loadSystemPrompt(['openharness', this.getModelPromptName(choice.id)], { + active_provider_model: choice.id, + active_user: this.describeUser(user).login, + available_tools: availableTools, + }), + maxSteps: 6, + tools: { + ...skillTools, + search_admin_links: searchAdminLinks, + navigate_admin: navigateAdmin, + }, + }); + session = new Session({agent, contextWindow: choice.contextWindow ?? this.contextWindow}); + session.__openharnessFingerprint = fingerprint; + session.__openharnessPublish = publish; + this.sessions.set(user.id, session); + return session; + } + +} diff --git a/fixture/apps/ai-assistant/prompts/common.txt b/fixture/apps/ai-assistant/prompts/common.txt new file mode 100644 index 00000000..6659f84d --- /dev/null +++ b/fixture/apps/ai-assistant/prompts/common.txt @@ -0,0 +1,7 @@ +You are the Adminizer fixture data assistant. +If the user asks who you are, say you are the Adminizer fixture data assistant. +Always reply in the same language as the user. +Use only the tools provided in this chat. Tool calls always apply the active user's permissions. +Do not claim shell or filesystem access. Do not invent tool output. +For data-dependent answers, inspect data before making a conclusion. +Never claim that an action succeeded unless a tool confirmed it. diff --git a/fixture/apps/ai-assistant/prompts/openharness-model-default.txt b/fixture/apps/ai-assistant/prompts/openharness-model-default.txt new file mode 100644 index 00000000..068bcd72 --- /dev/null +++ b/fixture/apps/ai-assistant/prompts/openharness-model-default.txt @@ -0,0 +1,2 @@ +Adapt the depth and tone to the selected model while preserving the same safety, permission, and tool-use rules. +Prefer accuracy over creativity when facts are requested. diff --git a/fixture/apps/ai-assistant/prompts/openharness-model-genius.txt b/fixture/apps/ai-assistant/prompts/openharness-model-genius.txt new file mode 100644 index 00000000..313b91e9 --- /dev/null +++ b/fixture/apps/ai-assistant/prompts/openharness-model-genius.txt @@ -0,0 +1 @@ +For multi-part questions, organize the answer clearly and verify relevant data before drawing conclusions. Keep the final response focused. diff --git a/fixture/apps/ai-assistant/prompts/openharness-model-regular.txt b/fixture/apps/ai-assistant/prompts/openharness-model-regular.txt new file mode 100644 index 00000000..d0a0ba51 --- /dev/null +++ b/fixture/apps/ai-assistant/prompts/openharness-model-regular.txt @@ -0,0 +1 @@ +Work methodically. For data questions, inspect the relevant records before answering and distinguish facts from conclusions. diff --git a/fixture/apps/ai-assistant/prompts/openharness-model-silly.txt b/fixture/apps/ai-assistant/prompts/openharness-model-silly.txt new file mode 100644 index 00000000..da17692e --- /dev/null +++ b/fixture/apps/ai-assistant/prompts/openharness-model-silly.txt @@ -0,0 +1 @@ +Be especially concise and direct. Do not guess; use a tool when the answer depends on application data. diff --git a/fixture/apps/ai-assistant/prompts/openharness.txt b/fixture/apps/ai-assistant/prompts/openharness.txt new file mode 100644 index 00000000..20a2e164 --- /dev/null +++ b/fixture/apps/ai-assistant/prompts/openharness.txt @@ -0,0 +1,8 @@ +You help administer the Adminizer fixture application. +Active admin user: {{active_user}}. +Use the data skills supplied in this chat to inspect data and to make changes only when the user explicitly asks. +Use list_admin_navigation when the user asks what the panel offers or where something lives: it returns their own menu, permissions applied. +Use search_admin_links before navigate_admin. Navigate only to a concrete link or template returned by that search. +When you mention a page in your answer, write its path exactly as the tool returned it (e.g. /admin/model/Test/edit/); never build a full URL with a host. +Active provider model for this chat: {{active_provider_model}}. +Available tools in this chat: {{available_tools}}. diff --git a/fixture/apps/contexts/ContextDemoApps.ts b/fixture/apps/contexts/ContextDemoApps.ts new file mode 100644 index 00000000..44eeb20a --- /dev/null +++ b/fixture/apps/contexts/ContextDemoApps.ts @@ -0,0 +1,243 @@ +import path from "path"; +import {routePrefix} from "../../adminizerConfig"; +import {AbstractAdminizerApp, Adminizer, AppAsset, AppSetupContext} from "../../../dist"; + +/** An ES module of this folder, built by `npm run build:context-apps`. */ +const asset = (id: string, name: string): AppAsset => ({ + id, + filePath: path.resolve(import.meta.dirname, `${name}.es.js`), + devUrl: `/fixture/apps/contexts/${name}.tsx`, +}); + +/** The component of every demo page. */ +const PAGE = asset("page", "ContextDemoPage"); + +/** Props of a demo page: what it says and where it links. */ +const text = (title: string, body: string, links: { title: string; href: string }[] = []) => ({ + data: {title, text: body, links}, +}); + +/** + * Host side: overrides a part of the default context's layout. Contexts that + * inherit the default layout (System below) get the override as well. + */ +export function configureDefaultContext(adminizer: Adminizer): void { + const overrides = adminizer.assetHandler.register("host", asset("default-shell-overrides", "DefaultShellOverrides")); + adminizer.contextHandler.configureDefault({layout: {module: overrides}}); +} + +/** + * Own navbar, inherited layout with two parts overridden: the cheapest kind + * of context. Its navbar also links to built-in model pages: Categories stays + * in the context while the user comes from it (cookie), Json schema always + * opens in it — its model declares `context: "system"` (fixture/adminizerConfig.ts). + */ +export class SystemContextApp extends AbstractAdminizerApp { + readonly name = "context-system"; + readonly version = "1.0.0"; + + constructor() { + super(); + } + + setup(ctx: AppSetupContext): void { + const system = ctx.context({ + id: "system", + title: "System", + icon: "settings", + prefix: "/system", + layout: {module: asset("overrides", "SystemShellOverrides")}, + navbar: {sections: {System: {icon: "settings", order: 1}, Data: {icon: "storage", order: 2}}}, + }); + system.page({ + id: "system-overview", title: "Overview", icon: "dashboard", path: "", section: "System", + component: PAGE, props: text("Overview", "Stock layout, context-specific navbar."), + }); + system.page({ + id: "system-settings", title: "Settings", icon: "tune", path: "/settings", section: "System", + component: PAGE, props: text("Settings", "A second page of the System context."), + }); + system.menuItem({ + id: "system-categories", title: "Categories (built-in page)", icon: "category", + link: `${routePrefix}/model/Category`, type: "self", section: "Data", + accessRightsToken: "read-Category-model", + }); + system.menuItem({ + id: "system-json-schema", title: "Json schema (declared page)", icon: "data_object", + link: `${routePrefix}/model/JsonSchema`, type: "self", section: "Data", + accessRightsToken: "read-JsonSchema-model", + }); + } +} + +/** + * Partially inherited layout: its own sidebar (the `Sidebar` part) next to + * the stock header (breadcrumbs, search, notifications, user menu). + */ +export class SupportContextApp extends AbstractAdminizerApp { + readonly name = "context-support"; + readonly version = "1.0.0"; + + constructor() { + super(); + } + + setup(ctx: AppSetupContext): void { + const support = ctx.context({ + id: "support", + title: "Support desk", + icon: "support_agent", + prefix: "/support", + layout: {module: asset("layout", "SupportLayout")}, + }); + support.page({ + id: "support-inbox", title: "Inbox", icon: "inbox", path: "", + component: PAGE, props: text("Inbox", "Own sidebar, stock header."), + }); + support.page({ + id: "support-open", title: "Open tickets", icon: "confirmation_number", path: "/tickets", badge: 7, + component: PAGE, props: text("Open tickets", "Tickets waiting for an answer."), + }); + support.page({ + id: "support-macros", title: "Macros", icon: "bolt", path: "/macros", + component: PAGE, props: text("Macros", "Canned replies."), + }); + } +} + +/** Fully custom layout: a top bar instead of the sidebar, nothing inherited. */ +export class StudioContextApp extends AbstractAdminizerApp { + readonly name = "context-studio"; + readonly version = "1.0.0"; + + constructor() { + super(); + } + + setup(ctx: AppSetupContext): void { + const studio = ctx.context({ + id: "studio", + title: "Studio", + icon: "movie", + prefix: "/studio", + layout: {module: asset("layout", "StudioLayout"), base: "stock"}, + }); + studio.page({ + id: "studio-home", title: "Projects", icon: "movie", path: "", + component: PAGE, props: text("Projects", "A layout written from scratch: top navigation, no sidebar."), + }); + studio.page({ + id: "studio-assets", title: "Assets", icon: "perm_media", path: "/assets", + component: PAGE, props: text("Assets", "Media of the studio."), + }); + studio.page({ + id: "studio-render", title: "Render queue", icon: "pending_actions", path: "/render", + component: PAGE, props: text("Render queue", "Jobs in progress."), + }); + } +} + +/** + * Host side: a context registered without an app. It inherits the default + * layout and overrides the remaining stock parts (the whole sidebar and + * header, the sidebar header and content, the header start) with a + * stylesheet of its own. The switcher leads to `home`, not to the prefix. + * Only users holding the `context-reports` token see the context and may + * open its pages: the token is given once, to the context. + */ +export function registerReportsContext(adminizer: Adminizer): void { + const token = "context-reports"; + adminizer.accessRightsHelper.registerToken({ + id: token, + name: "Reports", + description: "Access to the Reports context", + department: "Contexts", + }); + + const host = (resource: AppAsset) => adminizer.assetHandler.register("host", resource); + const page = host(asset("reports-page", "ContextDemoPage")); + const prefix = "/reports"; + const reports = adminizer.contextHandler.register({ + id: "reports", + title: "Reports", + icon: "bar_chart", + prefix, + home: `${prefix}/summary`, + accessRightsToken: token, + layout: { + module: host(asset("reports-overrides", "ReportsShellOverrides")), + stylesheet: host({ + id: "reports-overrides-css", + filePath: path.resolve(import.meta.dirname, "ReportsShellOverrides.css"), + }), + }, + navbar: {sections: {Reports: {icon: "bar_chart", order: 1}}}, + }); + + reports.page({ + id: "reports-summary", title: "Summary", icon: "summarize", path: "/summary", section: "Reports", + component: page, props: text("Summary", "Context home: inherited layout, five parts overridden, own stylesheet."), + }); + reports.page({ + id: "reports-overview", title: "Overview", icon: "insights", path: "", section: "Reports", + component: page, props: text("Overview", "The prefix page. The switcher leads to Summary instead (`home`)."), + }); + // Pages of a group: no menu items of their own, the group item lists them. + const daily = reports.page({ + id: "reports-daily", title: "Daily sales", icon: "today", path: "/sales/daily", menu: false, + component: page, props: text("Daily sales", "A sub-item of the Sales group."), + }); + const monthly = reports.page({ + id: "reports-monthly", title: "Monthly sales", icon: "calendar_month", path: "/sales/monthly", menu: false, + component: page, props: text("Monthly sales", "Another sub-item of the Sales group."), + }); + reports.menuItem({ + id: "reports-sales", title: "Sales", icon: "payments", link: daily.link, type: "self", section: "Reports", + subItems: [daily, monthly], + }); +} + +/** + * Three contexts of one app, for the remaining layout values: + * - Inherited: neither layout nor navbar given — everything comes from the + * default context, its overrides included; + * - Plain: `layout: "stock"` — the stock layout without any override; + * - Kiosk: `layout: "none"`, hidden from the switcher, reached by link only. + */ +export class VariantsContextApp extends AbstractAdminizerApp { + readonly name = "context-variants"; + readonly version = "1.0.0"; + + constructor() { + super(); + } + + setup(ctx: AppSetupContext): void { + const kiosk = `${routePrefix}/kiosk`; + + // `menu: false` keeps the inherited menu: a menu item would make it the context's own. + ctx.context({id: "inherited", title: "Inherited", icon: "account_tree", prefix: "/inherited"}).page({ + id: "inherited-home", title: "Inherited", icon: "account_tree", path: "", menu: false, + component: PAGE, props: text("Inherited", + "No layout and no navbar given: the default context's menu and its sidebar footer override are inherited.", + [{title: "Open the Kiosk context (hidden from the switcher)", href: kiosk}]), + }); + + const plain = ctx.context({id: "plain", title: "Plain", icon: "crop_square", prefix: "/plain", layout: "stock"}); + plain.page({ + id: "plain-home", title: "Plain", icon: "crop_square", path: "", + component: PAGE, props: text("Plain", "layout: \"stock\" — the stock layout without the default context's overrides (no badge in the footer)."), + }); + plain.page({ + id: "plain-second", title: "Second page", icon: "article", path: "/second", + component: PAGE, props: text("Second page", "Own navbar, stock layout."), + }); + + ctx.context({id: "kiosk", title: "Kiosk", icon: "tv", prefix: "/kiosk", layout: "none", switcher: false}).page({ + id: "kiosk-home", title: "Kiosk", icon: "tv", path: "", menu: false, + component: PAGE, props: text("Kiosk", + "layout: \"none\" — no shell at all. switcher: false — this context is not in the switcher.", + [{title: "Back to the panel", href: routePrefix}]), + }); + } +} diff --git a/fixture/apps/contexts/ContextDemoPage.es.js b/fixture/apps/contexts/ContextDemoPage.es.js new file mode 100644 index 00000000..197971ff --- /dev/null +++ b/fixture/apps/contexts/ContextDemoPage.es.js @@ -0,0 +1,54 @@ +var x = { exports: {} }, i = {}; +var a; +function p() { + if (a) return i; + a = 1; + var s = /* @__PURE__ */ Symbol.for("react.transitional.element"), r = /* @__PURE__ */ Symbol.for("react.fragment"); + function o(c, t, n) { + var d = null; + if (n !== void 0 && (d = "" + n), t.key !== void 0 && (d = "" + t.key), "key" in t) { + n = {}; + for (var l in t) + l !== "key" && (n[l] = t[l]); + } else n = t; + return t = n.ref, { + $$typeof: s, + type: c, + key: d, + ref: t !== void 0 ? t : null, + props: n + }; + } + return i.Fragment = r, i.jsx = o, i.jsxs = o, i; +} +var u; +function h() { + return u || (u = 1, x.exports = p()), x.exports; +} +var e = h(); +const j = window.InertiajsReact.Link, C = window.UIComponents.Card, m = window.UIComponents.CardContent, R = window.UIComponents.CardHeader, f = window.UIComponents.CardTitle, w = window.AdminizerShell.useAdminContext; +function v({ data: s }) { + const { context: r } = w(); + return /* @__PURE__ */ e.jsxs(C, { children: [ + /* @__PURE__ */ e.jsx(R, { children: /* @__PURE__ */ e.jsx(f, { children: s.title }) }), + /* @__PURE__ */ e.jsxs(m, { children: [ + /* @__PURE__ */ e.jsx("p", { children: s.text }), + s.links?.map((o) => /* @__PURE__ */ e.jsx("p", { style: { marginTop: 8 }, children: /* @__PURE__ */ e.jsx(j, { href: o.href, style: { textDecoration: "underline" }, children: o.title }) }, o.href)), + /* @__PURE__ */ e.jsxs("p", { style: { opacity: 0.6, marginTop: 8, fontSize: 13 }, children: [ + "context: ", + /* @__PURE__ */ e.jsx("code", { children: r?.id }), + " · prefix: ", + /* @__PURE__ */ e.jsx("code", { children: r?.prefix }), + " · layout root: ", + /* @__PURE__ */ e.jsx("code", { children: r?.layout.root }), + r?.layout.layers.length ? /* @__PURE__ */ e.jsxs(e.Fragment, { children: [ + " · layers: ", + /* @__PURE__ */ e.jsx("code", { children: r.layout.layers.length }) + ] }) : null + ] }) + ] }) + ] }); +} +export { + v as default +}; diff --git a/fixture/apps/contexts/ContextDemoPage.tsx b/fixture/apps/contexts/ContextDemoPage.tsx new file mode 100644 index 00000000..e8b51a54 --- /dev/null +++ b/fixture/apps/contexts/ContextDemoPage.tsx @@ -0,0 +1,30 @@ +import {Link} from "@inertiajs/react"; +import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card"; +import {useAdminContext} from "@/shell"; + +interface ContextDemoPageProps { + data: { title: string; text: string; links?: { title: string; href: string }[] }; +} + +export default function ContextDemoPage({data}: ContextDemoPageProps) { + const {context} = useAdminContext(); + return ( + + + {data.title} + + +

{data.text}

+ {data.links?.map((link) => ( +

+ {link.title} +

+ ))} +

+ context: {context?.id} · prefix: {context?.prefix} · layout root: {context?.layout.root} + {context?.layout.layers.length ? <> · layers: {context.layout.layers.length} : null} +

+
+
+ ); +} diff --git a/fixture/apps/contexts/DefaultShellOverrides.es.js b/fixture/apps/contexts/DefaultShellOverrides.es.js new file mode 100644 index 00000000..a25fe7fb --- /dev/null +++ b/fixture/apps/contexts/DefaultShellOverrides.es.js @@ -0,0 +1,45 @@ +var u = { exports: {} }, t = {}; +var d; +function p() { + if (d) return t; + d = 1; + var s = /* @__PURE__ */ Symbol.for("react.transitional.element"), l = /* @__PURE__ */ Symbol.for("react.fragment"); + function a(c, r, e) { + var i = null; + if (e !== void 0 && (i = "" + e), r.key !== void 0 && (i = "" + r.key), "key" in r) { + e = {}; + for (var o in r) + o !== "key" && (e[o] = r[o]); + } else e = r; + return r = e.ref, { + $$typeof: s, + type: c, + key: i, + ref: r !== void 0 ? r : null, + props: e + }; + } + return t.Fragment = l, t.jsx = a, t.jsxs = a, t; +} +var x; +function v() { + return x || (x = 1, u.exports = p()), u.exports; +} +var n = v(); +const R = { + margin: "0 auto 6px", + padding: "2px 8px", + borderRadius: 999, + fontSize: 11, + background: "var(--sidebar-accent)", + color: "var(--sidebar-accent-foreground)" +}; +function E({ Default: s }) { + return /* @__PURE__ */ n.jsxs(n.Fragment, { children: [ + /* @__PURE__ */ n.jsx("div", { style: R, className: "group-data-[state=collapsed]:hidden", children: "Fixture environment" }), + /* @__PURE__ */ n.jsx(s, {}) + ] }); +} +export { + E as SidebarFooter +}; diff --git a/fixture/apps/contexts/DefaultShellOverrides.tsx b/fixture/apps/contexts/DefaultShellOverrides.tsx new file mode 100644 index 00000000..35c4f853 --- /dev/null +++ b/fixture/apps/contexts/DefaultShellOverrides.tsx @@ -0,0 +1,20 @@ +import {type SlotProps} from "@/shell"; + +const badge = { + margin: "0 auto 6px", padding: "2px 8px", borderRadius: 999, fontSize: 11, + background: "var(--sidebar-accent)", color: "var(--sidebar-accent-foreground)", +}; + +/** + * Overrides of the default context, set by the host through + * `contextHandler.configureDefault()`. Contexts inheriting the default layout + * get them too. + */ +export function SidebarFooter({Default}: SlotProps) { + return ( + <> +
Fixture environment
+ + + ); +} diff --git a/fixture/apps/contexts/ReportsShellOverrides.css b/fixture/apps/contexts/ReportsShellOverrides.css new file mode 100644 index 00000000..7f3c2d10 --- /dev/null +++ b/fixture/apps/contexts/ReportsShellOverrides.css @@ -0,0 +1,79 @@ +/* Stylesheet of the Reports context (`layout.stylesheet`). */ + +.reports-rail { + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px 6px; +} + +.reports-rail a { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 8px; + opacity: 0.7; +} + +.reports-rail a:hover, +.reports-rail a.reports-rail-active { + opacity: 1; + background: var(--sidebar-accent); +} + +/* The mobile sidebar is a sheet; the rail would only take the page's width. */ +@media (max-width: 767px) { + .reports-rail { + display: none; + } +} + +.reports-caption { + padding: 0 8px; + font-size: 11px; + opacity: 0.6; +} + +.reports-periods { + display: flex; + gap: 4px; + padding: 0 8px; +} + +/* This stylesheet loads after the panel's, so `display` above would beat the + `group-data-[state=collapsed]:hidden` utility on the same element. */ +[data-state="collapsed"] .reports-periods { + display: none; +} + +.reports-periods button { + flex: 1; + padding: 2px 0; + border-radius: 6px; + font-size: 12px; + cursor: pointer; +} + +.reports-periods button.reports-period-active { + background: var(--sidebar-accent); + font-weight: 600; +} + +.reports-banner { + padding: 4px 16px; + font-size: 12px; + text-align: center; + background: var(--sidebar-accent); + border-top-left-radius: inherit; + border-top-right-radius: inherit; +} + +.reports-tag { + padding: 1px 8px; + border-radius: 999px; + font-size: 11px; + border: 1px solid var(--border); + white-space: nowrap; +} diff --git a/fixture/apps/contexts/ReportsShellOverrides.es.js b/fixture/apps/contexts/ReportsShellOverrides.es.js new file mode 100644 index 00000000..75bcb5d8 --- /dev/null +++ b/fixture/apps/contexts/ReportsShellOverrides.es.js @@ -0,0 +1,86 @@ +var l = { exports: {} }, o = {}; +var u; +function p() { + if (u) return o; + u = 1; + var t = /* @__PURE__ */ Symbol.for("react.transitional.element"), s = /* @__PURE__ */ Symbol.for("react.fragment"); + function a(i, n, r) { + var d = null; + if (r !== void 0 && (d = "" + r), n.key !== void 0 && (d = "" + n.key), "key" in n) { + r = {}; + for (var c in n) + c !== "key" && (r[c] = n[c]); + } else r = n; + return n = r.ref, { + $$typeof: t, + type: i, + key: d, + ref: n !== void 0 ? n : null, + props: r + }; + } + return o.Fragment = s, o.jsx = a, o.jsxs = a, o; +} +var x; +function j() { + return x || (x = 1, l.exports = p()), l.exports; +} +var e = j(); +const h = window.React.useState, m = window.InertiajsReact.Link, R = window.AdminizerShell.MaterialIcon, v = window.AdminizerShell.useAdminContext; +function k({ Default: t, ...s }) { + const { menu: a, isActive: i } = v(), n = a.flatMap((r) => r.actions?.length ? r.actions : [r]); + return /* @__PURE__ */ e.jsxs(e.Fragment, { children: [ + /* @__PURE__ */ e.jsx(t, { ...s }), + /* @__PURE__ */ e.jsx("nav", { className: "reports-rail", "aria-label": "Reports", children: n.map((r) => /* @__PURE__ */ e.jsx( + m, + { + href: r.link, + title: r.title, + className: i(r.link) ? "reports-rail-active" : void 0, + children: /* @__PURE__ */ e.jsx(R, { name: r.icon ?? "description", className: "!text-[20px]" }) + }, + r.id + )) }) + ] }); +} +function f({ Default: t }) { + return /* @__PURE__ */ e.jsxs(e.Fragment, { children: [ + /* @__PURE__ */ e.jsx(t, {}), + /* @__PURE__ */ e.jsx("div", { className: "reports-caption group-data-[state=collapsed]:hidden", children: "Analytics workspace" }) + ] }); +} +function S({ Default: t }) { + const [s, a] = h("Week"); + return /* @__PURE__ */ e.jsxs(e.Fragment, { children: [ + /* @__PURE__ */ e.jsx("div", { className: "reports-periods group-data-[state=collapsed]:hidden", children: ["Day", "Week", "Month"].map((i) => /* @__PURE__ */ e.jsx( + "button", + { + type: "button", + onClick: () => a(i), + className: i === s ? "reports-period-active" : void 0, + children: i + }, + i + )) }), + /* @__PURE__ */ e.jsx(t, {}) + ] }); +} +function w({ Default: t, ...s }) { + return /* @__PURE__ */ e.jsxs(e.Fragment, { children: [ + /* @__PURE__ */ e.jsx("div", { className: "reports-banner", children: "Demo data · refreshed nightly" }), + /* @__PURE__ */ e.jsx(t, { ...s }) + ] }); +} +function A({ Default: t, ...s }) { + return /* @__PURE__ */ e.jsxs(e.Fragment, { children: [ + /* @__PURE__ */ e.jsx(t, { ...s }), + /* @__PURE__ */ e.jsx("span", { className: "reports-tag", children: "Read-only" }) + ] }); +} +export { + w as Header, + A as HeaderStart, + k as Sidebar, + S as SidebarContent, + f as SidebarHeader +}; diff --git a/fixture/apps/contexts/ReportsShellOverrides.tsx b/fixture/apps/contexts/ReportsShellOverrides.tsx new file mode 100644 index 00000000..42020cbf --- /dev/null +++ b/fixture/apps/contexts/ReportsShellOverrides.tsx @@ -0,0 +1,77 @@ +import {useState} from "react"; +import {Link} from "@inertiajs/react"; +import {MaterialIcon, type SlotProps, useAdminContext} from "@/shell"; + +/** + * Overrides of the Reports context (registered by the host). Each one keeps + * the inherited part through `Default` and adds to it; the classes come from + * ReportsShellOverrides.css, the context's `layout.stylesheet`. + */ + +/** The whole sidebar: an icon rail of the context's pages next to it. */ +export function Sidebar({Default, ...props}: SlotProps) { + const {menu, isActive} = useAdminContext(); + const items = menu.flatMap((item): { id: string; title: string; link: string; icon?: string | null }[] => + item.actions?.length ? item.actions : [item]); + return ( + <> + + + + ); +} + +/** Sidebar header: a caption under the inherited context switcher. */ +export function SidebarHeader({Default}: SlotProps) { + return ( + <> + +
Analytics workspace
+ + ); +} + +/** Sidebar content: a period filter above the inherited menu. */ +export function SidebarContent({Default}: SlotProps) { + const [period, setPeriod] = useState("Week"); + return ( + <> +
+ {["Day", "Week", "Month"].map((value) => ( + + ))} +
+ + + ); +} + +/** The whole header: a banner above the inherited one. */ +export function Header({Default, ...props}: SlotProps) { + return ( + <> +
Demo data · refreshed nightly
+ + + ); +} + +/** Header start: a tag after the inherited toggle and breadcrumbs. */ +export function HeaderStart({Default, ...props}: SlotProps) { + return ( + <> + + Read-only + + ); +} diff --git a/fixture/apps/contexts/StudioLayout.es.js b/fixture/apps/contexts/StudioLayout.es.js new file mode 100644 index 00000000..95e5e4ea --- /dev/null +++ b/fixture/apps/contexts/StudioLayout.es.js @@ -0,0 +1,72 @@ +var c = { exports: {} }, s = {}; +var p; +function v() { + if (p) return s; + p = 1; + var l = /* @__PURE__ */ Symbol.for("react.transitional.element"), d = /* @__PURE__ */ Symbol.for("react.fragment"); + function a(u, e, n) { + var i = null; + if (n !== void 0 && (i = "" + n), e.key !== void 0 && (i = "" + e.key), "key" in e) { + n = {}; + for (var o in e) + o !== "key" && (n[o] = e[o]); + } else n = e; + return e = n.ref, { + $$typeof: l, + type: u, + key: i, + ref: e !== void 0 ? e : null, + props: n + }; + } + return s.Fragment = d, s.jsx = a, s.jsxs = a, s; +} +var h; +function g() { + return h || (h = 1, c.exports = v()), c.exports; +} +var r = g(); +const R = window.InertiajsReact.Link, j = window.InertiajsReact.usePage, k = window.AdminizerShell.useAdminContext, y = { + display: "flex", + alignItems: "center", + gap: 20, + height: 52, + padding: "0 20px", + background: "#18181b", + color: "#fafafa", + fontSize: 14, + flexShrink: 0, + // Narrow screens scroll the bar instead of overflowing the page. + overflowX: "auto", + whiteSpace: "nowrap" +}, w = { + background: "#27272a", + color: "inherit", + border: "1px solid #3f3f46", + borderRadius: 6, + padding: "4px 8px" +}; +function m({ children: l }) { + const { auth: d, logout: a, logoutBtn: u } = j().props, { context: e, contexts: n, menu: i, isActive: o, switchTo: f } = k(); + return /* @__PURE__ */ r.jsxs("div", { style: { display: "flex", flexDirection: "column", height: "100svh" }, children: [ + /* @__PURE__ */ r.jsxs("header", { style: y, children: [ + /* @__PURE__ */ r.jsx("strong", { style: { letterSpacing: 1 }, children: "▶ STUDIO" }), + /* @__PURE__ */ r.jsx("nav", { style: { display: "flex", gap: 4, flex: 1 }, children: i.map((t) => { + const x = o(t.link); + return /* @__PURE__ */ r.jsx(R, { href: t.link, style: { + padding: "6px 12px", + borderRadius: 6, + background: x ? "#fafafa" : "transparent", + color: x ? "#18181b" : "inherit" + }, children: t.title }, t.id); + }) }), + /* @__PURE__ */ r.jsx("select", { style: w, value: e?.id, onChange: (t) => f(t.target.value), children: n.map((t) => /* @__PURE__ */ r.jsx("option", { value: t.id, children: t.title }, t.id)) }), + /* @__PURE__ */ r.jsx("span", { style: { opacity: 0.7 }, children: d?.user?.login }), + /* @__PURE__ */ r.jsx("a", { href: a, style: { opacity: 0.7 }, children: u }) + ] }), + /* @__PURE__ */ r.jsx("main", { "data-scroll-region": "content", style: { flex: 1, minHeight: 0, overflowY: "auto", background: "var(--background)" }, children: l }) + ] }); +} +export { + m as Layout +}; diff --git a/fixture/apps/contexts/StudioLayout.tsx b/fixture/apps/contexts/StudioLayout.tsx new file mode 100644 index 00000000..fb81a240 --- /dev/null +++ b/fixture/apps/contexts/StudioLayout.tsx @@ -0,0 +1,52 @@ +import {type CSSProperties} from "react"; +import {Link, usePage} from "@inertiajs/react"; +import {type LayoutProps, useAdminContext} from "@/shell"; + +const bar: CSSProperties = { + display: "flex", alignItems: "center", gap: 20, height: 52, padding: "0 20px", + background: "#18181b", color: "#fafafa", fontSize: 14, flexShrink: 0, + // Narrow screens scroll the bar instead of overflowing the page. + overflowX: "auto", whiteSpace: "nowrap", +}; +const select: CSSProperties = { + background: "#27272a", color: "inherit", border: "1px solid #3f3f46", borderRadius: 6, padding: "4px 8px", +}; + +/** + * Fully custom layout (the `Layout` part): nothing of the stock shell is + * reused. Navigation and the context switcher come from `useAdminContext()`. + */ +export function Layout({children}: LayoutProps) { + const {auth, logout, logoutBtn} = usePage().props; + const {context, contexts, menu, isActive, switchTo} = useAdminContext(); + + return ( +
+
+ ▶ STUDIO + + + {auth?.user?.login} + {logoutBtn} +
+
+ {children} +
+
+ ); +} diff --git a/fixture/apps/contexts/SupportLayout.es.js b/fixture/apps/contexts/SupportLayout.es.js new file mode 100644 index 00000000..75cedc33 --- /dev/null +++ b/fixture/apps/contexts/SupportLayout.es.js @@ -0,0 +1,61 @@ +var u = { exports: {} }, t = {}; +var l; +function p() { + if (l) return t; + l = 1; + var c = /* @__PURE__ */ Symbol.for("react.transitional.element"), o = /* @__PURE__ */ Symbol.for("react.fragment"); + function i(s, e, r) { + var d = null; + if (r !== void 0 && (d = "" + r), e.key !== void 0 && (d = "" + e.key), "key" in e) { + r = {}; + for (var a in e) + a !== "key" && (r[a] = e[a]); + } else r = e; + return e = r.ref, { + $$typeof: c, + type: s, + key: d, + ref: e !== void 0 ? e : null, + props: r + }; + } + return t.Fragment = o, t.jsx = i, t.jsxs = i, t; +} +var x; +function w() { + return x || (x = 1, u.exports = p()), u.exports; +} +var n = w(); +const S = window.InertiajsReact.Link, h = window.InertiajsReact.usePage, b = window.UIComponents.Sidebar, j = window.UIComponents.SidebarContent, m = window.UIComponents.SidebarGroup, C = window.UIComponents.SidebarGroupLabel, R = window.UIComponents.SidebarHeader, v = window.UIComponents.SidebarMenu, I = window.UIComponents.SidebarMenuBadge, M = window.UIComponents.SidebarMenuButton, k = window.UIComponents.SidebarMenuItem, A = window.AdminizerShell.ContextSwitcher, U = window.AdminizerShell.MaterialIcon, E = window.AdminizerShell.useAdminContext; +function f(c) { + const o = h(), { menu: i, isActive: s } = E(); + return /* @__PURE__ */ n.jsxs(b, { collapsible: "icon", variant: "inset", children: [ + /* @__PURE__ */ n.jsx(R, { children: /* @__PURE__ */ n.jsx(A, { side: "right" }) }), + /* @__PURE__ */ n.jsxs(j, { children: [ + /* @__PURE__ */ n.jsxs(m, { children: [ + /* @__PURE__ */ n.jsx(C, { children: "Queues" }), + /* @__PURE__ */ n.jsx(v, { children: i.map((e) => /* @__PURE__ */ n.jsxs(k, { children: [ + /* @__PURE__ */ n.jsx(M, { asChild: !0, isActive: s(e.link), tooltip: e.title, children: /* @__PURE__ */ n.jsxs(S, { href: e.link, children: [ + e.icon && /* @__PURE__ */ n.jsx(U, { name: e.icon, className: "!text-[18px]" }), + /* @__PURE__ */ n.jsx("span", { children: e.title }) + ] }) }), + e.badge ? /* @__PURE__ */ n.jsx(I, { children: e.badge }) : null + ] }, e.id)) }) + ] }), + /* @__PURE__ */ n.jsxs( + "div", + { + style: { margin: "auto 12px 12px", padding: 12, borderRadius: 8, fontSize: 12, background: "var(--sidebar-accent)" }, + className: "group-data-[state=collapsed]:hidden", + children: [ + "On duty: ", + o.props.auth?.user?.login + ] + } + ) + ] }) + ] }); +} +export { + f as Sidebar +}; diff --git a/fixture/apps/contexts/SupportLayout.tsx b/fixture/apps/contexts/SupportLayout.tsx new file mode 100644 index 00000000..9262cfab --- /dev/null +++ b/fixture/apps/contexts/SupportLayout.tsx @@ -0,0 +1,52 @@ +import {Link, usePage} from "@inertiajs/react"; +import { + Sidebar as UISidebar, + SidebarContent, + SidebarGroup, + SidebarGroupLabel, + SidebarHeader, + SidebarMenu, + SidebarMenuBadge, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; +import {ContextSwitcher, MaterialIcon, type SlotProps, useAdminContext} from "@/shell"; + +/** + * The sidebar of the Support context, written here. The shell around it + * (sidebar state, mobile sheet) and the whole header stay the stock ones. + */ +export function Sidebar(_: SlotProps) { + const page = usePage(); + const {menu, isActive} = useAdminContext(); + + return ( + + + + + + + Queues + + {menu.map((item) => ( + + + + {item.icon && } + {item.title} + + + {item.badge ? {item.badge} : null} + + ))} + + +
+ On duty: {page.props.auth?.user?.login} +
+
+
+ ); +} diff --git a/fixture/apps/contexts/SystemShellOverrides.es.js b/fixture/apps/contexts/SystemShellOverrides.es.js new file mode 100644 index 00000000..6f615721 --- /dev/null +++ b/fixture/apps/contexts/SystemShellOverrides.es.js @@ -0,0 +1,59 @@ +var d = { exports: {} }, i = {}; +var u; +function p() { + if (u) return i; + u = 1; + var n = /* @__PURE__ */ Symbol.for("react.transitional.element"), o = /* @__PURE__ */ Symbol.for("react.fragment"); + function s(c, e, r) { + var a = null; + if (r !== void 0 && (a = "" + r), e.key !== void 0 && (a = "" + e.key), "key" in e) { + r = {}; + for (var x in e) + x !== "key" && (r[x] = e[x]); + } else r = e; + return e = r.ref, { + $$typeof: n, + type: c, + key: a, + ref: e !== void 0 ? e : null, + props: r + }; + } + return i.Fragment = o, i.jsx = s, i.jsxs = s, i; +} +var l; +function m() { + return l || (l = 1, d.exports = p()), d.exports; +} +var t = m(); +const R = window.InertiajsReact.Link, f = window.AdminizerShell.MaterialIcon, j = window.AdminizerShell.useAdminContext; +function v({ Default: n, ...o }) { + const { context: s } = j(); + return /* @__PURE__ */ t.jsxs(t.Fragment, { children: [ + /* @__PURE__ */ t.jsxs(R, { href: `${s?.prefix}/settings`, style: { + display: "inline-flex", + alignItems: "center", + gap: 6, + padding: "4px 10px", + borderRadius: 6, + fontSize: 13, + background: "var(--sidebar-accent)", + whiteSpace: "nowrap", + flexShrink: 0 + }, children: [ + /* @__PURE__ */ t.jsx(f, { name: "tune", className: "!text-[18px]" }), + "System settings" + ] }), + /* @__PURE__ */ t.jsx(n, { ...o }) + ] }); +} +function h({ Default: n }) { + return /* @__PURE__ */ t.jsxs(t.Fragment, { children: [ + /* @__PURE__ */ t.jsx(n, {}), + /* @__PURE__ */ t.jsx("div", { style: { textAlign: "center", fontSize: 11, opacity: 0.6 }, className: "group-data-[state=collapsed]:hidden", children: "System context" }) + ] }); +} +export { + v as HeaderActions, + h as SidebarFooter +}; diff --git a/fixture/apps/contexts/SystemShellOverrides.tsx b/fixture/apps/contexts/SystemShellOverrides.tsx new file mode 100644 index 00000000..b32f6fe6 --- /dev/null +++ b/fixture/apps/contexts/SystemShellOverrides.tsx @@ -0,0 +1,34 @@ +import {Link} from "@inertiajs/react"; +import {MaterialIcon, type SlotProps, useAdminContext} from "@/shell"; + +/** + * Overrides of the System context. Everything else is inherited from the + * default context, and `Default` is the inherited part, not necessarily the + * stock one: the footer below wraps the default context's footer override. + */ +export function HeaderActions({Default, ...props}: SlotProps) { + const {context} = useAdminContext(); + return ( + <> + + + System settings + + + + ); +} + +export function SidebarFooter({Default}: SlotProps) { + return ( + <> + +
+ System context +
+ + ); +} diff --git a/fixture/apps/contexts/vite.config.module.ts b/fixture/apps/contexts/vite.config.module.ts new file mode 100644 index 00000000..9e2a1403 --- /dev/null +++ b/fixture/apps/contexts/vite.config.module.ts @@ -0,0 +1,45 @@ +import {defineConfig} from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; +import {viteExternalsPlugin} from "vite-plugin-externals"; + +// One entry per run: CONTEXT_ENTRY=SupportLayout vite build --config ... +const entry = process.env.CONTEXT_ENTRY || 'ContextDemoPage'; + +const externals: Record = { + react: 'React', + 'react-dom': 'ReactDOM', + '@inertiajs/react': 'InertiajsReact', + '@/components/ui/card': 'UIComponents', + '@/components/ui/sidebar': 'UIComponents', + // Stock shell parts, published by the admin layout. + '@/shell': 'AdminizerShell', +}; + +export default defineConfig({ + define: { + 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production'), + }, + plugins: [ + react(), + viteExternalsPlugin(externals), + ], + build: { + outDir: path.resolve(import.meta.dirname, ''), + emptyOutDir: false, + lib: { + entry: path.resolve(import.meta.dirname, entry), + name: entry, + formats: ['es'], + fileName: (format) => `${entry}.${format}.js`, + }, + rollupOptions: { + external: ['tailwindcss', ...Object.keys(externals).filter((name) => name.startsWith('@/'))], + }, + }, + resolve: { + alias: { + '@': path.resolve(import.meta.dirname, '../../../src/assets/js'), + }, + }, +}); diff --git a/fixture/apps/handsontable-test/HandsontableTest.es.js b/fixture/apps/handsontable-test/HandsontableTest.es.js new file mode 100644 index 00000000..bc9f2878 --- /dev/null +++ b/fixture/apps/handsontable-test/HandsontableTest.es.js @@ -0,0 +1,65 @@ +var d = { exports: {} }, s = {}; +var l; +function p() { + if (l) return s; + l = 1; + var r = /* @__PURE__ */ Symbol.for("react.transitional.element"), o = /* @__PURE__ */ Symbol.for("react.fragment"); + function a(x, e, t) { + var u = null; + if (t !== void 0 && (u = "" + t), e.key !== void 0 && (u = "" + e.key), "key" in e) { + t = {}; + for (var i in e) + i !== "key" && (t[i] = e[i]); + } else t = e; + return e = t.ref, { + $$typeof: r, + type: x, + key: u, + ref: e !== void 0 ? e : null, + props: t + }; + } + return s.Fragment = o, s.jsx = a, s.jsxs = a, s; +} +var c; +function m() { + return c || (c = 1, d.exports = p()), d.exports; +} +var n = m(); +const R = window.React.useMemo, v = window.React.useState, f = window.JSComponents.HandsonTable, h = [ + { feature: "window.JSComponents", status: "loaded", value: 1 }, + { feature: "HandsonTable", status: "rendered", value: 2 }, + { feature: "production asset", status: "checked", value: 3 } +]; +function w() { + const [r, o] = v(h), a = R(() => ({ + colHeaders: ["Feature", "Status", "Value"], + columns: [ + { data: "feature", type: "text" }, + { data: "status", type: "text" }, + { data: "value", type: "numeric" } + ], + rowHeaders: !0, + stretchH: "all", + height: 320, + width: "100%", + licenseKey: "non-commercial-and-evaluation" + }), []); + return /* @__PURE__ */ n.jsxs("section", { className: "space-y-4 p-4", children: [ + /* @__PURE__ */ n.jsxs("div", { children: [ + /* @__PURE__ */ n.jsx("h1", { className: "text-xl font-semibold", children: "Handsontable Test" }), + /* @__PURE__ */ n.jsx("p", { className: "text-sm text-muted-foreground", children: "Source: @/js-components/handsontable" }) + ] }), + /* @__PURE__ */ n.jsx( + f, + { + data: r, + config: a, + onChange: o + } + ) + ] }); +} +export { + w as default +}; diff --git a/fixture/apps/handsontable-test/HandsontableTest.tsx b/fixture/apps/handsontable-test/HandsontableTest.tsx new file mode 100644 index 00000000..a8441d44 --- /dev/null +++ b/fixture/apps/handsontable-test/HandsontableTest.tsx @@ -0,0 +1,48 @@ +import {useMemo, useState} from "react"; +import HandsonTable from "@/js-components/handsontable"; + +type TableRow = { + feature: string; + status: string; + value: number; +}; + +const initialRows: TableRow[] = [ + {feature: "window.JSComponents", status: "loaded", value: 1}, + {feature: "HandsonTable", status: "rendered", value: 2}, + {feature: "production asset", status: "checked", value: 3}, +]; + +export default function HandsontableTest() { + const [rows, setRows] = useState(initialRows); + const options = useMemo(() => ({ + colHeaders: ["Feature", "Status", "Value"], + columns: [ + {data: "feature", type: "text"}, + {data: "status", type: "text"}, + {data: "value", type: "numeric"}, + ], + rowHeaders: true, + stretchH: "all", + height: 320, + width: "100%", + licenseKey: "non-commercial-and-evaluation", + }), []); + + return ( +
+
+

Handsontable Test

+

+ Source: @/js-components/handsontable +

+
+ + +
+ ); +} diff --git a/fixture/apps/handsontable-test/HandsontableTestApp.ts b/fixture/apps/handsontable-test/HandsontableTestApp.ts new file mode 100644 index 00000000..f9e7c87c --- /dev/null +++ b/fixture/apps/handsontable-test/HandsontableTestApp.ts @@ -0,0 +1,74 @@ +import path from "path"; +import {AbstractAdminizerApp, AppSetupContext} from "../../../dist"; + +interface HandsontableTestAppConfig { + route: string; + sidebarId: string; + title: string; + icon: string; + section: string; + componentFile: string; + devComponentUrl: string; +} + +export class HandsontableTestApp extends AbstractAdminizerApp { + readonly name = "handsontable-test"; + readonly version = "1.0.0"; + declare readonly config: HandsontableTestAppConfig; + + constructor(config: Partial = {}) { + super(); + this.config = { + route: "/handsontable-test", + sidebarId: "handsontable-test", + title: "Handsontable Test", + icon: "table_chart", + section: "Platform", + componentFile: path.resolve(import.meta.dirname, "HandsontableTest.es.js"), + devComponentUrl: "/fixture/apps/handsontable-test/HandsontableTest.tsx", + ...config, + }; + } + + setup(ctx: AppSetupContext): void { + const moduleComponent = ctx.asset({ + id: "component", + filePath: this.config.componentFile, + devUrl: this.config.devComponentUrl, + }); + + const pageUrl = ctx.controller({ + id: "page", + method: "get", + route: this.config.route, + middleware: this.renderModule(moduleComponent), + policies: [{type: "auth", mode: "ui"}], + }); + + ctx.config({ + navbar: { + additionalLinks: [{ + id: this.config.sidebarId, + link: pageUrl, + type: "self", + title: this.config.title, + icon: this.config.icon as any, + section: this.config.section, + }], + }, + }); + } + + private renderModule(moduleComponent: string): MiddlewareType { + return async (req, res) => { + return req.Inertia.render({ + component: "module", + props: { + moduleComponent, + moduleLayout: "bare", + data: {}, + }, + }); + }; + } +} diff --git a/fixture/apps/handsontable-test/vite.config.module.ts b/fixture/apps/handsontable-test/vite.config.module.ts new file mode 100644 index 00000000..d0b6c282 --- /dev/null +++ b/fixture/apps/handsontable-test/vite.config.module.ts @@ -0,0 +1,38 @@ +import {defineConfig} from "vite"; +import react from "@vitejs/plugin-react"; +import path from "path"; +import {viteExternalsPlugin} from "vite-plugin-externals"; + +export default defineConfig({ + define: { + "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "production"), + }, + plugins: [ + react(), + viteExternalsPlugin({ + react: "React", + "react-dom": "ReactDOM", + "@/js-components/handsontable": ["JSComponents", "HandsonTable"], + }), + ], + build: { + outDir: path.resolve(import.meta.dirname, ""), + emptyOutDir: false, + lib: { + entry: path.resolve(import.meta.dirname, "HandsontableTest"), + name: "HandsontableTest", + formats: ["es"], + fileName: (format) => `HandsontableTest.${format}.js`, + }, + rollupOptions: { + external: [ + "@/js-components/handsontable", + ], + }, + }, + resolve: { + alias: { + "@": path.resolve(import.meta.dirname, "../../../src/assets/js"), + }, + }, +}); diff --git a/fixture/apps/media-manager/DefaultMediaManager.ts b/fixture/apps/media-manager/DefaultMediaManager.ts new file mode 100644 index 00000000..b03df909 --- /dev/null +++ b/fixture/apps/media-manager/DefaultMediaManager.ts @@ -0,0 +1,152 @@ +import { + AbstractMediaManager, + Adminizer, + AppRuntime, + File, + MediaManagerItem, + MediaManagerWidgetClientItem, + MediaManagerWidgetData, + QueryCriteria, + SortCriteria, + populateVariants +} from "../../../dist"; +import {ApplicationItem, ImageItem, TextItem, VideoItem} from "./Items"; +import {mediaManagerModelNames} from "./MediaManagerModels"; +import type {MediaManagerAssociationRecord} from "./MediaManagerTypes"; + +export class DefaultMediaManager extends AbstractMediaManager { + readonly itemTypes: File[] = []; + readonly id: string; + declare readonly urlPathPrefix: string; + declare readonly fileStoragePath: string; + + constructor( + private readonly runtime: AppRuntime, + id: string, + urlPathPrefix: string, + fileStoragePath: string, + imageSizes: Record + ) { + super(createLegacyMediaManagerHost()); + this.id = id; + this.urlPathPrefix = urlPathPrefix; + this.fileStoragePath = fileStoragePath; + this.itemTypes.push(new ImageItem(runtime, urlPathPrefix, fileStoragePath, imageSizes)); + this.itemTypes.push(new TextItem(runtime, urlPathPrefix, fileStoragePath)); + this.itemTypes.push(new ApplicationItem(runtime, urlPathPrefix, fileStoragePath)); + this.itemTypes.push(new VideoItem(runtime, urlPathPrefix, fileStoragePath)); + } + + async getAll(limit: number, skip: number, sort: SortCriteria, group?: string) { + const media = this.runtime.models.get(mediaManagerModelNames.media); + const where: QueryCriteria["where"] = {parent: null, group}; + const data = await media.find({ + where, + limit, + skip, + sort, + populate: {variants: {sort}, meta: true}, + }); + for (const item of data) { + item.variants = await populateVariants( + this.runtime, + item.variants ?? [], + mediaManagerModelNames.media + ); + } + const next = await media.find({where, limit, skip: skip + limit, sort}); + return {data, next: next.length > 0}; + } + + async searchAll(search: string, group?: string): Promise { + const data = await this.runtime.models + .get(mediaManagerModelNames.media) + .find({ + where: {filename: {contains: search}, parent: null, group}, + sort: "createdAt DESC", + limit: 1000, + populate: {variants: {sort: "createdAt DESC"}, meta: true}, + }); + for (const item of data) { + item.variants = await populateVariants( + this.runtime, + item.variants ?? [], + mediaManagerModelNames.media + ); + } + return data; + } + + async setRelations( + data: MediaManagerWidgetData[], + model: string, + modelId: string | number, + widgetName: string + ): Promise { + if (modelId == null) { + throw new Error("modelId must be a string or number"); + } + + const associations = this.runtime.models.get( + mediaManagerModelNames.associations + ); + const where = { + modelId: String(modelId), + model: model.toLowerCase(), + widgetName, + }; + for (const association of await associations.find({where})) { + await associations.destroy({where: {id: association.id}}); + } + for (const [index, item] of data.entries()) { + await associations.create({ + mediaManagerId: this.id, + model: model.toLowerCase(), + modelId: String(modelId), + file: item.id, + widgetName, + sortOrder: index + 1, + }); + } + } + + async getRelations( + model: string, + widgetName: string, + modelId: string | number + ): Promise { + if (modelId == null) { + throw new Error("modelId must be a string or number"); + } + + const files = await this.runtime.models.get( + mediaManagerModelNames.associations + ).find({ + where: { + model: model.toLowerCase(), + widgetName, + modelId: String(modelId), + }, + sort: "sortOrder ASC", + populate: {file: true}, + }); + + return files + .filter((association) => association.file) + .map((association) => ({ + id: association.file.id, + mimeType: association.file.mimeType, + filename: association.file.filename, + url: association.file.url, + variants: [] as MediaManagerItem[], + })); + } +} + +function createLegacyMediaManagerHost(): Adminizer { + return { + accessRightsHelper: { + registerToken: (): void => undefined, + }, + } as unknown as Adminizer; +} diff --git a/fixture/apps/media-manager/Items.ts b/fixture/apps/media-manager/Items.ts new file mode 100644 index 00000000..497cabd3 --- /dev/null +++ b/fixture/apps/media-manager/Items.ts @@ -0,0 +1,337 @@ +import { + AppRuntime, + File, + MediaFileType, + MediaManagerItem, + QueryCriteria, + SortCriteria, + UploaderFile, + populateVariants +} from "../../../dist"; +import sizeOf from "image-size"; +import sharp from "sharp"; +import * as fs from "fs"; +import path from "path"; +import {mediaManagerModelNames} from "./MediaManagerModels"; +import type { + MediaManagerAssociationRecord, + MediaManagerMetaRecord, + PublicMediaManagerMeta, +} from "./MediaManagerTypes"; + +type ImageSizes = Record; + +export class ImageItem extends File { + readonly type: MediaFileType = "image"; + + constructor( + protected readonly runtime: AppRuntime, + urlPathPrefix: string, + fileStoragePath: string, + private readonly imageSizes: ImageSizes = {} + ) { + super(urlPathPrefix, fileStoragePath); + } + + protected media() { + return this.runtime.models.get(mediaManagerModelNames.media); + } + + protected meta() { + return this.runtime.models.get(mediaManagerModelNames.meta); + } + + protected associations() { + return this.runtime.models.get( + mediaManagerModelNames.associations + ); + } + + async getItems(limit: number, skip: number, sort: SortCriteria, group?: string) { + const where: QueryCriteria["where"] = { + parent: null, + mimeType: {contains: this.type}, + group, + }; + const data = await this.media().find({ + where, + limit, + skip, + sort, + populate: {variants: {sort}, meta: true}, + }); + for (const item of data) { + item.variants = await populateVariants( + this.runtime, + item.variants ?? [], + mediaManagerModelNames.media + ); + } + const next = await this.media().find({where, limit, skip: skip + limit, sort}); + return {data, next: next.length > 0}; + } + + async search(search: string, group?: string): Promise { + const data = await this.media().find({ + where: { + filename: {contains: search}, + mimeType: {contains: this.type}, + parent: null, + group, + }, + sort: "createdAt DESC", + populate: {variants: {sort: "createdAt DESC"}, meta: true}, + }); + for (const item of data) { + item.variants = await populateVariants( + this.runtime, + item.variants ?? [], + mediaManagerModelNames.media + ); + } + return data; + } + + async upload( + file: UploaderFile, + filename: string, + originalName: string, + group?: string + ): Promise { + const parent = await this.media().create({ + parent: null, + mimeType: file.mimetype, + size: file.size, + path: this.storagePath(filename), + group, + tag: "origin", + filename: originalName, + url: this.publicUrl(filename), + }); + await this.createMeta(parent.id); + await this.addImageSizeMeta(file.path, parent.id); + if (Object.keys(this.imageSizes).length && file.mimetype !== "image/svg+xml") { + await this.createVariants(file, parent, filename, group); + } + return [await this.getFile(parent.id)]; + } + + async getVariants(id: string): Promise { + const item = await this.media().findOne({ + where: {id}, + populate: {variants: {sort: "createdAt DESC"}}, + }); + return populateVariants( + this.runtime, + item?.variants ?? [], + mediaManagerModelNames.media + ); + } + + async getOrigin(id: string): Promise { + return (await this.media().findOne({where: {id}})).path; + } + + async getFile(id: number | string): Promise { + const item = await this.media().findOne({ + where: {id: String(id)}, + populate: {variants: {sort: "createdAt DESC"}, meta: true}, + }); + if (item) { + item.variants = await populateVariants( + this.runtime, + item.variants ?? [], + mediaManagerModelNames.media + ); + } + return item; + } + + async getMeta(id: string): Promise { + const item = await this.media().findOne({ + where: {id}, + populate: {meta: {where: {isPublic: true}}}, + }); + return (item?.meta ?? []).flatMap((meta) => + typeof meta.key === "string" && typeof meta.value === "string" + ? [{key: meta.key, value: meta.value}] + : [] + ); + } + + async setMeta(id: string, data: Record): Promise { + for (const [key, value] of Object.entries(data)) { + await this.meta().update({where: {parentId: id, key}}, {value}); + } + } + + async uploadVariant( + parent: MediaManagerItem, + file: UploaderFile, + filename: string, + group?: string, + localeId?: string + ): Promise { + const dimensions = sizeOf(fs.readFileSync(file.path)); + const item = await this.media().create({ + parent: parent.id, + mimeType: file.mimetype, + size: file.size, + path: file.path, + group, + tag: localeId ? `loc:${localeId}` : `size:${dimensions.width}x${dimensions.height}`, + filename: parent.filename, + url: this.publicUrl(filename), + }); + await this.addImageSizeMeta(file.path, item.id); + return this.media().findOne({where: {id: item.id}}); + } + + async delete(id: string): Promise { + if ((await this.associations().find({where: {fileId: id}})).length) { + return false; + } + + const criteria: QueryCriteria = {where: {id}}; + const record = await this.media().findOne({ + ...criteria, + populate: {variants: true, meta: true}, + }); + if (!record) { + return true; + } + + for (const meta of record.meta ?? []) { + await this.meta().destroy({where: {id: meta.id}}); + } + for (const variant of record.variants ?? []) { + await this.media().destroy({where: {id: variant.id}}); + await deleteFile(variant.path); + } + await this.media().destroy(criteria); + await deleteFile(record.path); + return true; + } + + protected async createMeta(id: string): Promise { + for (const key of ["author", "description", "title"]) { + await this.meta().create({key, value: "", parent: id, isPublic: true}); + } + } + + private async addImageSizeMeta(filePath: string, id: string): Promise { + await this.meta().create({ + key: "imageSizes", + value: sizeOf(fs.readFileSync(filePath)), + parent: id, + isPublic: false, + }); + } + + private async createVariants( + file: UploaderFile, + parent: MediaManagerItem, + filename: string, + group?: string + ): Promise { + const dimensions = sizeOf(fs.readFileSync(file.path)); + for (const [sizeName, target] of Object.entries(this.imageSizes)) { + if (dimensions.width < target.width || dimensions.height < target.height) { + continue; + } + + const variantName = addFileSuffix(filename, sizeName); + const output = this.storagePath(variantName); + await fs.promises.mkdir(path.dirname(output), {recursive: true}); + const resized = await sharp(file.path) + .resize({width: target.width, height: target.height}) + .toFile(output); + const variant = await this.media().create({ + parent: parent.id, + mimeType: parent.mimeType, + size: resized.size, + filename: parent.filename, + group, + path: output, + tag: `size:${sizeName}`, + url: this.publicUrl(variantName), + }); + await this.addImageSizeMeta(output, variant.id); + } + } + + protected storagePath(filename: string): string { + return path.join(this.fileStoragePath, this.urlPathPrefix, filename); + } + + protected publicUrl(filename: string): string { + return `/${this.urlPathPrefix}/${filename}`; + } +} + +export class TextItem extends ImageItem { + readonly type: MediaFileType = "text"; + + async upload( + file: UploaderFile, + filename: string, + originalName: string, + group?: string + ): Promise { + const item = await this.media().create({ + parent: null, + mimeType: file.mimetype, + size: file.size, + path: this.storagePath(filename), + group, + filename: originalName, + tag: "origin", + url: this.publicUrl(filename), + }); + await this.createMeta(item.id); + return [await this.getFile(item.id)]; + } + + async uploadVariant( + parent: MediaManagerItem, + file: UploaderFile, + filename: string, + group?: string, + localeId?: string + ): Promise { + const variants = (parent.variants ?? []).filter((item) => !/^loc:/.test(item.tag)); + const item = await this.media().create({ + parent: parent.id, + mimeType: file.mimetype, + size: file.size, + path: file.path, + group, + tag: localeId ? `loc:${localeId}` : `ver:${variants.length + 1}`, + filename: parent.filename, + url: this.publicUrl(filename), + }); + return this.media().findOne({where: {id: item.id}}); + } +} + +export class ApplicationItem extends TextItem { + readonly type: MediaFileType = "application"; +} + +export class VideoItem extends TextItem { + readonly type: MediaFileType = "video"; +} + +function addFileSuffix(filename: string, suffix: string): string { + return filename.replace(/\.[^.]+$/, `_${suffix}$&`); +} + +async function deleteFile(filePath: string): Promise { + try { + await fs.promises.unlink(filePath); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } +} diff --git a/fixture/apps/media-manager/MediaManagerApp.ts b/fixture/apps/media-manager/MediaManagerApp.ts new file mode 100644 index 00000000..9d37a03b --- /dev/null +++ b/fixture/apps/media-manager/MediaManagerApp.ts @@ -0,0 +1,56 @@ +import { + AbstractAdminizerApp, + AppSetupContext, + MediaManagerConfig, +} from "../../../dist"; +import {DefaultMediaManager} from "./DefaultMediaManager"; +import {mediaManagerModelNames} from "./MediaManagerModels"; + +export interface DefaultMediaManagerAppConfig extends MediaManagerConfig { + id?: string; + urlPathPrefix?: string; +} + +export class MediaManagerApp extends AbstractAdminizerApp { + readonly name = "media-manager"; + readonly version = "1.0.0"; + declare readonly config: DefaultMediaManagerAppConfig; + + constructor(config: DefaultMediaManagerAppConfig) { + super(); + this.config = { + ...config, + id: config.id ?? "default", + urlPathPrefix: config.urlPathPrefix ?? "media-manager", + }; + } + + setup(ctx: AppSetupContext): void { + const permissionToken = `mediaManager-${this.config.id}`; + + ctx.accessRight({ + id: permissionToken, + name: this.config.id, + description: `Access to edit media-manager for ${this.config.id}`, + department: "media-manager", + }); + + for (const modelName of Object.values(mediaManagerModelNames)) { + ctx.model({name: modelName, adapter: "sequelize"}); + } + ctx.modelAccess({ + id: "storage", + models: Object.values(mediaManagerModelNames), + }); + ctx.mediaManager({ + factory: (runtime) => + new DefaultMediaManager( + runtime, + this.config.id, + this.config.urlPathPrefix, + this.config.fileStoragePath, + this.config.imageSizes ?? {} + ), + }); + } +} diff --git a/fixture/apps/media-manager/MediaManagerModels.ts b/fixture/apps/media-manager/MediaManagerModels.ts new file mode 100644 index 00000000..08402fad --- /dev/null +++ b/fixture/apps/media-manager/MediaManagerModels.ts @@ -0,0 +1,79 @@ +import { + DataTypes, + Model, + ModelAttributes, + ModelStatic, + Sequelize, +} from "sequelize"; + +export const mediaManagerModelNames = { + media: "MediaManagerAP", + meta: "MediaManagerMetaAP", + associations: "MediaManagerAssociationsAP", +} as const; + +export async function installMediaManagerSequelizeModels( + orm: Sequelize, + sync: boolean = false +): Promise { + const Media = getOrDefine(orm, mediaManagerModelNames.media, { + id: {type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true}, + parentId: {type: DataTypes.INTEGER, allowNull: true}, + mimeType: {type: DataTypes.STRING, allowNull: false}, + path: {type: DataTypes.STRING, allowNull: false}, + size: {type: DataTypes.INTEGER, allowNull: false}, + group: DataTypes.STRING, + tag: DataTypes.STRING, + url: {type: DataTypes.STRING, allowNull: false}, + filename: {type: DataTypes.STRING, allowNull: false}, + }); + const Meta = getOrDefine(orm, mediaManagerModelNames.meta, { + id: {type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true}, + key: {type: DataTypes.STRING, allowNull: false}, + value: DataTypes.JSON, + isPublic: {type: DataTypes.BOOLEAN, defaultValue: true}, + parentId: {type: DataTypes.INTEGER, allowNull: false}, + }); + const Associations = getOrDefine(orm, mediaManagerModelNames.associations, { + id: {type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true}, + mediaManagerId: {type: DataTypes.STRING, allowNull: false}, + model: {type: DataTypes.STRING, allowNull: false}, + modelId: {type: DataTypes.STRING, allowNull: false}, + widgetName: {type: DataTypes.STRING, allowNull: false}, + sortOrder: {type: DataTypes.INTEGER, allowNull: false}, + fileId: {type: DataTypes.INTEGER, allowNull: false}, + }); + + if (!Media.associations.parent) { + Media.belongsTo(Media, {as: "parent", foreignKey: "parentId"}); + Media.hasMany(Media, {as: "variants", foreignKey: "parentId"}); + } + if (!Media.associations.meta) { + Media.hasMany(Meta, {as: "meta", foreignKey: "parentId"}); + Meta.belongsTo(Media, {as: "parent", foreignKey: "parentId"}); + } + if (!Media.associations.modelAssociation) { + Media.hasMany(Associations, {as: "modelAssociation", foreignKey: "fileId"}); + Associations.belongsTo(Media, {as: "file", foreignKey: "fileId"}); + } + + if (sync) { + await Media.sync(); + await Meta.sync(); + await Associations.sync(); + } +} + +function getOrDefine( + orm: Sequelize, + name: string, + attributes: ModelAttributes> +): ModelStatic { + const existing = Object.values(orm.models).find( + (model) => model.name.toLowerCase() === name.toLowerCase() + ); + return existing ?? orm.define(name, attributes, { + tableName: name.toLowerCase(), + timestamps: true, + }); +} diff --git a/fixture/apps/media-manager/MediaManagerTypes.ts b/fixture/apps/media-manager/MediaManagerTypes.ts new file mode 100644 index 00000000..6750f72b --- /dev/null +++ b/fixture/apps/media-manager/MediaManagerTypes.ts @@ -0,0 +1,26 @@ +import type {MediaManagerItem} from "../../../dist"; + +export interface MediaManagerMetaRecord { + id?: string; + key: string; + value: unknown; + isPublic: boolean; + parentId?: string; + parent?: MediaManagerItem; +} + +export interface MediaManagerAssociationRecord { + id?: string; + mediaManagerId: string; + model: string; + modelId: string; + widgetName: string; + sortOrder: number; + fileId?: string; + file?: MediaManagerItem; +} + +export interface PublicMediaManagerMeta { + key: string; + value: string; +} diff --git a/fixture/apps/module-manager/ModuleManager.es.js b/fixture/apps/module-manager/ModuleManager.es.js new file mode 100644 index 00000000..e884c6ac --- /dev/null +++ b/fixture/apps/module-manager/ModuleManager.es.js @@ -0,0 +1,101 @@ +var p = { exports: {} }, a = {}; +var C; +function R() { + if (C) return a; + C = 1; + var c = /* @__PURE__ */ Symbol.for("react.transitional.element"), u = /* @__PURE__ */ Symbol.for("react.fragment"); + function o(x, t, e) { + var r = null; + if (e !== void 0 && (r = "" + e), t.key !== void 0 && (r = "" + t.key), "key" in t) { + e = {}; + for (var s in t) + s !== "key" && (e[s] = t[s]); + } else e = t; + return t = e.ref, { + $$typeof: c, + type: x, + key: r, + ref: t !== void 0 ? t : null, + props: e + }; + } + return a.Fragment = u, a.jsx = o, a.jsxs = o, a; +} +var j; +function v() { + return j || (j = 1, p.exports = R()), p.exports; +} +var n = v(); +const f = window.React.useState, U = window.UIComponents.Button, $ = window.UIComponents.Table, I = window.UIComponents.TableBody, l = window.UIComponents.TableCell, i = window.UIComponents.TableHead, g = window.UIComponents.TableHeader, b = window.UIComponents.TableRow, y = window.UIComponents.Card, E = window.UIComponents.CardContent, k = window.UIComponents.CardHeader, M = window.UIComponents.CardTitle, d = window.sonner.toast, T = window.axios, m = window.InertiajsReact.router, _ = window.JSComponents.DeleteModal; +function H({ data: c }) { + const [u, o] = f(c.modules), x = async (e, r) => { + const s = r === "enabled" ? "disable" : "enable"; + try { + await T.post(`${window.routePrefix}/module-manager/${s}`, { name: e }), d.success(`Модуль ${e} успешно ${s === "enable" ? "включён" : "выключен"}.`), o( + (h) => h.map( + (w) => w.name === e ? { ...w, state: s === "enable" ? "enabled" : "disabled" } : w + ) + ), m.reload(); + } catch { + d.error(`Ошибка при ${s === "enable" ? "включении" : "выключении"} модуля ${e}.`); + } + }, t = async (e) => { + console.log("handleUnregister called for:", e); + try { + await T.post(`${window.routePrefix}/module-manager/unregister`, { name: e }), d.success(`Модуль ${e} успешно удалён.`), o((r) => r.filter((s) => s.name !== e)), m.reload(); + } catch { + d.error(`Ошибка при удалении модуля ${e}.`); + } + }; + return /* @__PURE__ */ n.jsx(n.Fragment, { children: /* @__PURE__ */ n.jsxs(y, { className: "py-4", children: [ + /* @__PURE__ */ n.jsx(k, { children: /* @__PURE__ */ n.jsx(M, { children: "Менеджер модулей" }) }), + /* @__PURE__ */ n.jsx(E, { children: /* @__PURE__ */ n.jsxs($, { children: [ + /* @__PURE__ */ n.jsx(g, { children: /* @__PURE__ */ n.jsxs(b, { children: [ + /* @__PURE__ */ n.jsx(i, { children: "Имя" }), + /* @__PURE__ */ n.jsx(i, { children: "Версия" }), + /* @__PURE__ */ n.jsx(i, { children: "Статус" }), + /* @__PURE__ */ n.jsx(i, { children: "Действия" }) + ] }) }), + /* @__PURE__ */ n.jsx(I, { children: u.map((e) => /* @__PURE__ */ n.jsxs(b, { children: [ + /* @__PURE__ */ n.jsx(l, { children: e.name }), + /* @__PURE__ */ n.jsx(l, { children: e.version }), + /* @__PURE__ */ n.jsx(l, { children: /* @__PURE__ */ n.jsx( + "span", + { + className: `px-2 py-1 rounded text-xs font-medium ${e.state === "enabled" ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"}`, + children: e.state + } + ) }), + /* @__PURE__ */ n.jsxs(l, { className: "flex gap-x-4", children: [ + /* @__PURE__ */ n.jsx( + U, + { + onClick: () => x(e.name, e.state), + variant: e.state === "enabled" ? "destructive" : "default", + size: "sm", + children: e.state === "enabled" ? "Выключить" : "Включить" + } + ), + /* @__PURE__ */ n.jsx( + _, + { + btnTitle: "Удалить", + variant: "destructive", + btnCLass: "", + handleDelete: () => t(e.name), + isLink: !1, + delModal: { + yes: "Да", + no: "Нет", + text: `Вы уверены, что хотите удалить модуль ${e.name}? Это действие нельзя отменить.` + } + } + ) + ] }) + ] }, e.name)) }) + ] }) }) + ] }) }); +} +export { + H as default +}; diff --git a/fixture/apps/module-manager/ModuleManager.tsx b/fixture/apps/module-manager/ModuleManager.tsx new file mode 100644 index 00000000..7d8210aa --- /dev/null +++ b/fixture/apps/module-manager/ModuleManager.tsx @@ -0,0 +1,110 @@ +import {useState} from "react"; +import {Button} from "@/components/ui/button"; +import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"; +import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card"; +import {toast} from "sonner"; +import axios from "@/lib/axios-compat"; +import {router} from "@inertiajs/react"; +import DeleteModal from "@/components/modals/del-modal"; + +interface Module { + name: string; + version: string; + state: string; +} + +interface ModuleManagerProps { + data: { + modules: Module[]; + }; +} + +export default function ModuleManager({data}: ModuleManagerProps) { + const [modules, setModules] = useState(data.modules); + + const handleToggle = async (name: string, currentState: string) => { + const action = currentState === 'enabled' ? 'disable' : 'enable'; + try { + await axios.post(`${window.routePrefix}/module-manager/${action}`, {name}); + toast.success(`Модуль ${name} успешно ${action === 'enable' ? 'включён' : 'выключен'}.`); + setModules(prevModules => + prevModules.map(mod => + mod.name === name + ? {...mod, state: action === 'enable' ? 'enabled' : 'disabled'} + : mod + ) + ); + router.reload() + } catch (e) { + toast.error(`Ошибка при ${action === 'enable' ? 'включении' : 'выключении'} модуля ${name}.`); + } + + }; + + const handleUnregister = async (name: string) => { + console.log('handleUnregister called for:', name); + try { + await axios.post(`${window.routePrefix}/module-manager/unregister`, {name}); + toast.success(`Модуль ${name} успешно удалён.`); + setModules(prevModules => prevModules.filter(mod => mod.name !== name)); + router.reload(); + } catch (e) { + toast.error(`Ошибка при удалении модуля ${name}.`); + } + }; + + return ( + <> + + + Менеджер модулей + + + + + + Имя + Версия + Статус + Действия + + + + {modules.map((mod) => ( + + {mod.name} + {mod.version} + + {mod.state} + + + + handleUnregister(mod.name)} + isLink={false} + delModal={{ + yes: 'Да', + no: 'Нет', + text: `Вы уверены, что хотите удалить модуль ${mod.name}? Это действие нельзя отменить.` + }} + /> + + + ))} + +
+
+
+ + ); +} diff --git a/fixture/apps/module-manager/ModuleManagerApp.ts b/fixture/apps/module-manager/ModuleManagerApp.ts new file mode 100644 index 00000000..a92f899f --- /dev/null +++ b/fixture/apps/module-manager/ModuleManagerApp.ts @@ -0,0 +1,184 @@ +import path from "path"; +import {AbstractAdminizerApp, AppSetupContext} from "../../../dist"; + +const MODULE_MANAGER_ACCESS_TOKEN = "module-manager"; + +interface ModuleManagerAppConfig { + route: string; + sidebarId: string; + title: string; + icon: string; + section: string; + componentFile: string; + devComponentUrl: string; +} + +export class ModuleManagerApp extends AbstractAdminizerApp { + readonly name = "module-manager"; + readonly version = "1.0.0"; + declare readonly config: ModuleManagerAppConfig; + + constructor(config: Partial = {}) { + super(); + this.config = { + route: "/module-manager", + sidebarId: "module-manager", + title: "Module Manager", + icon: "settings", + section: "Platform", + componentFile: path.resolve(import.meta.dirname, "ModuleManager.es.js"), + devComponentUrl: "/fixture/apps/module-manager/ModuleManager.tsx", + ...config, + }; + } + + setup(ctx: AppSetupContext): void { + ctx.accessRight({ + id: MODULE_MANAGER_ACCESS_TOKEN, + name: "Module manager", + description: "Access to module manager", + department: "Modules", + }); + + const moduleComponent = ctx.asset({ + id: "component", + filePath: this.config.componentFile, + devUrl: this.config.devComponentUrl, + }); + + const pageUrl = ctx.controller({ + id: "page", + method: "get", + route: this.config.route, + middleware: this.renderModule(moduleComponent), + policies: [{type: "permission", token: MODULE_MANAGER_ACCESS_TOKEN, mode: "ui"}], + }); + + ctx.controller({ + id: "enable", + method: "post", + route: `${this.config.route}/enable`, + middleware: this.enableModule, + policies: [{type: "permission", token: MODULE_MANAGER_ACCESS_TOKEN, mode: "api"}], + }); + + ctx.controller({ + id: "disable", + method: "post", + route: `${this.config.route}/disable`, + middleware: this.disableModule, + policies: [{type: "permission", token: MODULE_MANAGER_ACCESS_TOKEN, mode: "api"}], + }); + + ctx.controller({ + id: "unregister", + method: "post", + route: `${this.config.route}/unregister`, + middleware: this.unregisterModule, + policies: [{type: "permission", token: MODULE_MANAGER_ACCESS_TOKEN, mode: "api"}], + }); + + ctx.config({ + navbar: { + additionalLinks: [ + { + id: this.config.sidebarId, + link: pageUrl, + type: "self", + title: this.config.title, + icon: this.config.icon as any, + section: this.config.section, + accessRightsToken: MODULE_MANAGER_ACCESS_TOKEN, + }, + ], + }, + }); + } + + private renderModule(moduleComponent: string): MiddlewareType { + return async (req, res) => { + const modules = req.runtime.apps.list() + .filter(({name}) => name !== "module-manager") + .map(({name, version, state}) => ({ + name, + version, + state, + })); + + return req.Inertia.render({ + component: "module", + props: { + moduleComponent, + breadcrumbs: [{title: "Module manager"}], + data: { + modules + }, + }, + }); + }; + } + + private enableModule: MiddlewareType = async (req, res) => { + const {name} = req.body; + if (!name) { + return res.status(400).json({error: "Name is required"}); + } + + if (name === "module-manager") { + return res.status(400).json({error: "The 'module-manager' app cannot be enabled through this interface"}); + } + + try { + await req.runtime.apps.enable(name); + return res.json({ + success: true, + }); + } catch (error) { + req.flash.setFlashMessage('error', `Failed to enable app ${name}.`); + return res.status(500).json({error: error.message}); + } + }; + + private disableModule: MiddlewareType = async (req, res) => { + const {name} = req.body; + if (!name) { + return res.status(400).json({error: "Name is required"}); + } + + if (name === "module-manager") { + return res.status(400).json({error: "The 'module-manager' app cannot be disabled"}); + } + + try { + await req.runtime.apps.disable(name); + return res.json({ + success: true + }) + } catch (error) { + req.flash.setFlashMessage('error', `Failed to disable app ${name}.`); + return res.status(500).json({error: error.message}); + } + }; + + private unregisterModule: MiddlewareType = async (req, res) => { + const {name} = req.body; + if (!name) { + return res.status(400).json({error: "Name is required"}); + } + + if (name === "module-manager") { + return res.status(400).json({error: "The 'module-manager' app cannot be unregistered"}); + } + + try { + await req.runtime.apps.unregister(name); + + return res.json({ + success: true + }) + } catch (error) { + req.flash.setFlashMessage('error', `Failed to unregister app ${name}.`); + return res.status(500).json({error: error.message}); + } + }; +} diff --git a/fixture/apps/module-manager/vite.config.module.ts b/fixture/apps/module-manager/vite.config.module.ts new file mode 100644 index 00000000..ea327059 --- /dev/null +++ b/fixture/apps/module-manager/vite.config.module.ts @@ -0,0 +1,104 @@ +import {defineConfig} from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; +import {viteExternalsPlugin} from "vite-plugin-externals"; + +export default defineConfig({ + define: { + 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production'), + }, + plugins: [ + react(), + viteExternalsPlugin({ + react: 'React', + axios: 'axios', + sonner: 'sonner', + 'lucide-react': 'LucideReact', + 'react-dom': 'ReactDOM', + '@/components/ui/avatar': 'UIComponents', + '@/components/ui/badge': 'UIComponents', + '@/components/ui/breadcrumb': 'UIComponents', + '@/components/ui/button': 'UIComponents', + '@/components/ui/calendar': 'UIComponents', + '@/components/ui/card': 'UIComponents', + '@/components/ui/checkbox': 'UIComponents', + '@/components/ui/collapsible': 'UIComponents', + '@/components/ui/command': 'UIComponents', + '@/components/ui/context-menu': 'UIComponents', + '@/components/ui/dialog': 'UIComponents', + '@/components/ui/dialog-stack': 'UIComponents', + '@/components/ui/dropdown-menu': 'UIComponents', + '@/components/ui/input': 'UIComponents', + '@/components/ui/label': 'UIComponents', + '@/components/ui/menubar': 'UIComponents', + '@/components/ui/pagination': 'UIComponents', + '@/components/ui/popover': 'UIComponents', + '@/components/ui/select': 'UIComponents', + '@/components/ui/separator': 'UIComponents', + '@/components/ui/sheet': 'UIComponents', + '@/components/ui/sidebar': 'UIComponents', + '@/components/ui/skeleton': 'UIComponents', + '@/components/ui/slider': 'UIComponents', + '@/components/ui/sonner': 'UIComponents', + '@/components/ui/switch': 'UIComponents', + '@/components/ui/table': 'UIComponents', + '@/components/ui/textarea': 'UIComponents', + '@/components/ui/tooltip': 'UIComponents', + '@/components/modals/del-modal': ['JSComponents', 'DeleteModal'], + '@/lib/axios-compat': 'axios', + '@inertiajs/react': 'InertiajsReact' + }), + ], + build: { + outDir: path.resolve(import.meta.dirname, ''), + emptyOutDir: false, + lib: { + entry: path.resolve(import.meta.dirname, 'ModuleManager'), + name: 'ModuleManager', + formats: ['es'], + fileName: (format) => `ModuleManager.${format}.js`, + }, + rollupOptions: { + external: [ + 'tailwindcss', + '@/components/ui/avatar', + '@/components/ui/badge', + '@/components/ui/breadcrumb', + '@/components/ui/button', + '@/components/ui/calendar', + '@/components/ui/card', + '@/components/ui/checkbox', + '@/components/ui/collapsible', + '@/components/ui/command', + '@/components/ui/context-menu', + '@/components/ui/dialog', + '@/components/ui/dialog-stack', + '@/components/ui/dropdown-menu', + '@/components/ui/input', + '@/components/ui/label', + '@/components/ui/menubar', + '@/components/ui/pagination', + '@/components/ui/popover', + '@/components/ui/select', + '@/components/ui/separator', + '@/components/ui/sheet', + '@/components/ui/sidebar', + '@/components/ui/skeleton', + '@/components/ui/slider', + '@/components/ui/sonner', + '@/components/ui/switch', + '@/components/ui/table', + '@/components/ui/textarea', + '@/components/ui/tooltip', + '@/components/modals/del-modal', + '@/lib/axios-compat', + '@inertiajs/react' + ], + }, + }, + resolve: { + alias: { + '@': path.resolve(import.meta.dirname, '../../../src/assets/js'), + }, + }, +}); diff --git a/fixture/apps/navigation/NavigationApp.ts b/fixture/apps/navigation/NavigationApp.ts new file mode 100644 index 00000000..59133f70 --- /dev/null +++ b/fixture/apps/navigation/NavigationApp.ts @@ -0,0 +1,145 @@ +import path from "path"; +import {AbstractAdminizerApp, AppSetupContext} from "../../../dist"; +import {NavigationCatalog} from "./NavigationCatalog"; +import {navigationModelName} from "./NavigationModel"; +import type {NavigationAppConfig} from "./NavigationTypes"; + +const NAVIGATION_ACCESS_TOKEN = "catalog-navigation"; + +export class NavigationApp extends AbstractAdminizerApp { + readonly name = "navigation"; + readonly version = "1.0.0"; + declare readonly config: NavigationAppConfig; + + constructor(config: NavigationAppConfig) { + super(); + this.config = { + ...config, + model: config.model ?? navigationModelName, + routePrefix: config.routePrefix ?? "", + componentFile: config.componentFile ?? path.resolve(import.meta.dirname, "NavigationCatalogTemplates.es.js"), + devComponentUrl: config.devComponentUrl ?? "/fixture/apps/navigation/NavigationCatalogTemplates.tsx", + }; + } + + setup(ctx: AppSetupContext): void { + let navigationCatalog: NavigationCatalog | undefined; + + ctx.accessRight({ + id: NAVIGATION_ACCESS_TOKEN, + name: "Navigation", + description: "Access to edit navigation catalogs", + department: "Catalog", + }); + + const catalogTemplates = ctx.asset({ + id: "catalog-templates", + filePath: this.config.componentFile, + devUrl: this.config.devComponentUrl, + }); + + ctx.model({ + name: this.config.model, + }); + + ctx.modelAccess({ + models: [ + this.config.model, + ...this.config.items.map((item) => item.model), + ], + }); + + ctx.config({ + models: { + [this.config.model.toLowerCase()]: { + add: false, + fields: { + createdAt: {visible: false}, + updatedAt: {visible: false}, + }, + navbar: { + visible: false, + }, + icon: "storage", + identifierField: "", + list: { + fields: { + tree: {visible: false}, + id: {visible: false}, + }, + }, + model: this.config.model.toLowerCase(), + remove: false, + title: this.config.model, + tools: [], + }, + }, + navbar: { + additionalLinks: this.config.sections.map((section) => ({ + id: `navigation-${section}`, + type: "self", + link: `${this.config.routePrefix}/catalog/navigation/${section}`, + title: `Nav ${section.charAt(0).toUpperCase()}${section.slice(1)}`, + icon: "menu" as any, + accessRightsToken: NAVIGATION_ACCESS_TOKEN, + })), + }, + }); + + ctx.catalog({ + id: "navigation", + templates: [ + { + id: "model-link-template", + type: "navigation.model-link", + component: catalogTemplates, + exportName: "NavigationModelLinkTemplate", + }, + { + id: "group-template", + type: "navigation.group", + component: catalogTemplates, + exportName: "NavigationGroupTemplate", + }, + { + id: "link-template", + type: "navigation.link", + component: catalogTemplates, + exportName: "NavigationLinkTemplate", + }, + ], + factory: async (runtime) => { + const catalog = new NavigationCatalog(runtime, this.config); + await catalog.ready(); + navigationCatalog = catalog; + return catalog; + }, + }); + + ctx.listener("model:updated", async (event: { + modelName: string; + record: Record; + }) => { + if (!navigationCatalog) { + return; + } + + const itemConfig = this.config.items.find((item) => + item.model.toLowerCase() === event.modelName.toLowerCase() + ); + if (!itemConfig) { + return; + } + + for (const section of this.config.sections) { + navigationCatalog.setId(section); + const navItem = navigationCatalog.itemTypes.find((item) => + item.type === event.modelName.toLowerCase() + ); + if (navItem) { + await navItem.updateModelItems(event.record.id, {record: event.record}, section); + } + } + }); + } +} diff --git a/fixture/apps/navigation/NavigationCatalog.ts b/fixture/apps/navigation/NavigationCatalog.ts new file mode 100644 index 00000000..e19082e7 --- /dev/null +++ b/fixture/apps/navigation/NavigationCatalog.ts @@ -0,0 +1,575 @@ +import { + AbstractCatalog, + AbstractGroup, + AbstractItem, + ActionHandler, + AppRuntime, + BaseItem, + Item, + ModelConfig, +} from "../../../dist"; +import {v4 as uuid} from "uuid"; +import {NavigationModel} from "./NavigationModel"; +import type {NavigationAppConfig} from "./NavigationTypes"; + +export interface NavItem extends Item { + urlPath?: string; + modelId?: string | number; + targetBlank?: boolean; + visible?: boolean; +} + +export class NavigationStorage { + protected storageMap: Map = new Map(); + public readonly ready: Promise; + + constructor( + private runtime: AppRuntime, + private id: string, + private model: string + ) { + this.ready = this.initModel(); + } + + private navigationModel() { + return this.runtime.models.get(this.model); + } + + public async initModel(): Promise { + const navigation = await this.navigationModel().findOne({where: {label: this.id}}); + if (navigation) { + await this.populateFromTree(navigation.tree as unknown as any[]); + return; + } + + await this.navigationModel().create({ + label: this.id, + tree: [] as any, + }); + } + + public getId(): string { + return this.id; + } + + public async buildTree(): Promise { + const rootElements = await this.findElementsByParentId(null, null); + const buildSubTree = async (elements: NavItem[]): Promise => { + const tree = []; + for (const element of elements) { + const children = await this.findElementsByParentId(element.id, null); + tree.push({ + ...element, + children: await buildSubTree(children), + }); + } + return tree; + }; + + const tree = await buildSubTree(rootElements); + const sortTree = (items: any[]) => { + items.sort((a, b) => a.sortOrder - b.sortOrder); + for (const item of items) { + if (item.children) { + sortTree(item.children); + } + } + }; + + sortTree(tree); + return tree; + } + + public async populateFromTree(tree: any[]): Promise { + const traverseTree = async (node: any): Promise => { + const {children, ...itemData} = node; + const item = { + ...itemData, + parentId: itemData.parentId === 0 ? null : itemData.parentId, + } as NavItem; + await this.setElement(item.id, item, true); + + if (children?.length) { + for (const child of children) { + await traverseTree(child); + } + } + }; + + for (const node of tree) { + await traverseTree(node); + } + } + + public async setElement(_id: string | number, item: NavItem, init = false): Promise { + this.storageMap.set(item.id, item); + if (!init) { + await this.saveToDB(); + } + return this.findElementById(item.id); + } + + public async removeElementById(id: string | number): Promise { + this.storageMap.delete(id); + await this.saveToDB(); + } + + public async findElementById(id: string | number): Promise { + return this.storageMap.get(id); + } + + public async findElementByModelId(modelId: string | number): Promise { + const elements: NavItem[] = []; + for (const item of this.storageMap.values()) { + if (item.modelId === modelId) { + elements.push(item); + } + } + return elements; + } + + public async saveToDB(): Promise { + const tree = await this.buildTree(); + await this.navigationModel().update( + {where: {label: this.id}}, + {tree: tree as any} + ); + } + + public async findElementsByParentId(parentId: string | number | null, type: string | null): Promise { + const elements: NavItem[] = []; + const normalizedParentId = parentId === 0 ? null : parentId; + for (const item of this.storageMap.values()) { + if (type === null && item.parentId === normalizedParentId) { + elements.push(item); + continue; + } + if (item.parentId === normalizedParentId && item.type === type) { + elements.push(item); + } + } + return elements; + } + + public async search(s: string, type: string): Promise { + const lowerCaseQuery = s.toLowerCase(); + return Array.from(this.storageMap.values()).filter((item) => + item.type === type && item.name.toLowerCase().includes(lowerCaseQuery) + ); + } +} + +export class NavigationStorageServices { + protected storages: NavigationStorage[] = []; + + public add(storage: NavigationStorage): void { + this.storages.push(storage); + } + + public get(id: string): NavigationStorage | undefined { + return this.storages.find((storage) => storage.getId() === id); + } + + public async ready(): Promise { + await Promise.all(this.storages.map((storage) => storage.ready)); + } +} + +export class NavigationCatalog extends AbstractCatalog { + readonly name = "Navigation"; + readonly slug = "navigation"; + public readonly icon = "box"; + public readonly actionHandlers: ActionHandler[] = []; + public idList: string[] = []; + public storageServices: NavigationStorageServices; + + constructor(private runtime: AppRuntime, config: NavigationAppConfig) { + const storageServices = new NavigationStorageServices(); + const items: BaseItem[] = config.items.map((configElement) => new NavigationItem( + runtime, + configElement.title, + configElement.model, + config.model, + configElement.urlPath as string, + storageServices + )); + + items.push(new NavigationGroup(config.groupField, storageServices)); + items.push(new LinkItem(storageServices)); + + super(createLegacyCatalogHost(), items); + + this.storageServices = storageServices; + for (const section of config.sections) { + this.storageServices.add(new NavigationStorage(runtime, section, config.model)); + } + + this.movingGroupsRootOnly = config.movingGroupsRootOnly; + this.idList = config.sections ?? []; + } + + async getIdList(): Promise { + return this.idList; + } + + async ready(): Promise { + await this.storageServices.ready(); + } +} + +class NavigationItem extends AbstractItem { + readonly allowedRoot = true; + readonly icon: string; + readonly name: string; + readonly type: string; + public readonly adminizer = createLegacyCatalogHost(); + public readonly actionHandlers: ActionHandler[] = []; + + constructor( + private runtime: AppRuntime, + name: string, + private model: string, + private navigationModel: string, + private urlPath: string, + storageServices: NavigationStorageServices + ) { + super(); + this.name = name; + this.type = model.toLowerCase(); + const configModel = runtime.config.getModelConfig(model) as ModelConfig; + this.icon = configModel?.icon ?? "file_present"; + (this as any).storageServices = storageServices; + } + + private contentModel() { + return this.runtime.models.get(this.model); + } + + async create(data: any, catalogId: string): Promise { + const storage = this.getStorage(catalogId); + let storageData: NavItem; + if (data._method === "select") { + const record = await this.contentModel().findOne({where: {id: data.record}}); + storageData = await this.dataPreparation({ + record, + parentId: data.parentId, + targetBlank: data.targetBlank, + visible: data.visible, + }, catalogId); + } else { + storageData = await this.dataPreparation(data, catalogId); + } + return await storage.setElement(data.id, storageData); + } + + protected async dataPreparation(data: any, catalogId: string, sortOrder?: number): Promise { + const storage = this.getStorage(catalogId); + const record = data.record; + const urlPath = this.renderUrlPath(record); + let parentId = data.parentId ? data.parentId : null; + if (parentId === 0) parentId = null; + + return { + id: uuid(), + modelId: record.id, + targetBlank: data.targetBlank ?? record.targetBlank, + visible: data.visible ?? record.visible, + isNavigation: true, + name: record.name ?? record.title ?? record.id, + parentId, + sortOrder: sortOrder ?? (await storage.findElementsByParentId(parentId, null)).length, + icon: this.icon, + type: this.type, + urlPath, + } as NavItem; + } + + async updateModelItems(modelId: string | number, data: any, catalogId: string): Promise { + const storage = this.getStorage(catalogId); + const items = await storage.findElementByModelId(modelId); + const response = []; + for (const item of items) { + item.name = data.record.name ?? data.record.title ?? data.record.id; + item.urlPath = this.renderUrlPath(data.record); + if (item.id === data.record.treeId) { + item.targetBlank = data.record.targetBlank; + item.visible = data.record.visible; + } + response.push(await storage.setElement(item.id, item)); + } + return response[0]; + } + + async update(itemId: string | number, data: any, catalogId: string): Promise { + return await this.getStorage(catalogId).setElement(itemId, data); + } + + async deleteItem(itemId: string | number, catalogId: string): Promise { + await this.getStorage(catalogId).removeElementById(itemId); + } + + async find(itemId: string | number, catalogId: string): Promise { + return await this.getStorage(catalogId).findElementById(itemId); + } + + async getAddTemplate(req: ReqType): Promise<{ + type: string; + data: { + items: { id: string; name: string }[]; + model: string; + labels?: Record; + }; + }> { + const itemsDB = await this.contentModel().find({}); + const items = itemsDB.map((item: any) => ({ + id: item.id, + name: item.name ?? item.title ?? item.id, + })); + return { + type: "navigation.model-link", + data: { + items, + model: this.model, + labels: { + selectTitle: `${req.i18n.__("Select")} ${req.i18n.__(this.name + "s")}`, + createTitle: `${req.i18n.__("create new")} ${req.i18n.__(this.name + "s")}`, + OR: req.i18n.__("OR"), + openInNewWindow: req.i18n.__("Open in a new window"), + visible: req.i18n.__("Visible"), + }, + }, + }; + } + + async getChilds(parentId: string | number | null, catalogId: string): Promise { + return await this.getStorage(catalogId).findElementsByParentId(parentId, this.type); + } + + async getEditTemplate(id: string | number, catalogId: string): Promise<{ + type: string; + data: { item: NavItem; model: string }; + }> { + return { + type: "navigation.model-link", + data: { + item: await this.find(id, catalogId), + model: this.model, + }, + }; + } + + async search(s: string, catalogId: string): Promise { + return await this.getStorage(catalogId).search(s, this.type); + } + + private getStorage(catalogId: string): NavigationStorage { + const storage = ((this as any).storageServices as NavigationStorageServices).get(catalogId); + if (!storage) { + throw new Error(`Navigation storage "${catalogId}" was not found`); + } + return storage; + } + + private renderUrlPath(record: any): string { + return this.urlPath.replace(/\$\{data\.record\.([^}]+)\}/g, (_match, field) => + encodeURIComponent(record?.[field] ?? "") + ); + } +} + +class NavigationGroup extends AbstractGroup { + readonly allowedRoot = true; + readonly name: string = "Group"; + readonly groupField: object[]; + public readonly adminizer = createLegacyCatalogHost(); + + constructor(groupField: object[], storageServices: NavigationStorageServices) { + super(); + this.groupField = groupField; + (this as any).storageServices = storageServices; + } + + async create(data: any, catalogId: string): Promise { + let storageData = await this.dataPreparation(data, catalogId); + delete data.name; + delete data.parentId; + storageData = {...storageData, ...data}; + return await this.getStorage(catalogId).setElement(storageData.id, storageData); + } + + protected async dataPreparation(data: any, catalogId: string, sortOrder?: number): Promise { + const storage = this.getStorage(catalogId); + let parentId = data.parentId ? data.parentId : null; + if (parentId === 0) parentId = null; + return { + id: uuid(), + name: data.name, + targetBlank: data.targetBlank, + visible: data.visible, + isNavigation: true, + parentId, + sortOrder: sortOrder ?? (await storage.findElementsByParentId(parentId, null)).length, + icon: this.icon, + type: this.type, + } as NavItem; + } + + async deleteItem(itemId: string | number, catalogId: string): Promise { + await this.getStorage(catalogId).removeElementById(itemId); + } + + async find(itemId: string | number, catalogId: string): Promise { + return await this.getStorage(catalogId).findElementById(itemId); + } + + async update(itemId: string | number, data: any, catalogId: string): Promise { + return await this.getStorage(catalogId).setElement(itemId, data); + } + + async updateModelItems(modelId: string | number, data: NavItem, catalogId: string): Promise { + return await this.getStorage(catalogId).setElement(modelId, data); + } + + getAddTemplate(req: ReqType): Promise<{ + type: string; + data: { + items?: { name: string; required: boolean }[] | Record[]; + model?: string; + labels?: Record; + }; + }> { + const items = this.groupField.map((field: any) => ({ + name: field.name, + label: field.label, + required: field.required, + })); + return Promise.resolve({ + type: "navigation.group", + data: { + items, + labels: { + openInNewWindow: req.i18n.__("Open in a new window"), + visible: req.i18n.__("Visible"), + title: req.i18n.__("Title"), + save: req.i18n.__("Save"), + }, + }, + }); + } + + async getEditTemplate(id: string | number, catalogId: string, req: ReqType): Promise<{ + type: string; + data: { + items?: { name: string; required: boolean }[] | Record[]; + item?: NavItem; + labels?: Record; + }; + }> { + const items = this.groupField.map((field: any) => ({ + name: field.name, + label: field.label, + required: field.required, + })); + return { + type: "navigation.group", + data: { + items, + item: await this.find(id, catalogId), + labels: { + openInNewWindow: req.i18n.__("Open in a new window"), + visible: req.i18n.__("Visible"), + title: req.i18n.__("Title"), + save: req.i18n.__("Save"), + }, + }, + }; + } + + async getChilds(parentId: string | number | null, catalogId: string): Promise { + return await this.getStorage(catalogId).findElementsByParentId(parentId, this.type); + } + + async search(s: string, catalogId: string): Promise { + return await this.getStorage(catalogId).search(s, this.type); + } + + private getStorage(catalogId: string): NavigationStorage { + const storage = ((this as any).storageServices as NavigationStorageServices).get(catalogId); + if (!storage) { + throw new Error(`Navigation storage "${catalogId}" was not found`); + } + return storage; + } +} + +class LinkItem extends NavigationGroup { + readonly allowedRoot = true; + readonly icon = "insert_link"; + readonly name: string = "Link"; + readonly type = "link"; + readonly isGroup = false; + + constructor(storageServices: NavigationStorageServices) { + super([], storageServices); + } + + getAddTemplate(req: ReqType): Promise<{ + type: string; + data: { + items?: { name: string; required: boolean; label: string }[]; + labels?: Record; + }; + }> { + return Promise.resolve({ + type: "navigation.link", + data: { + items: [{ + label: req.i18n.__("Link"), + name: "link", + required: true, + }], + labels: { + title: req.i18n.__("Title"), + openInNewWindow: req.i18n.__("Open in a new window"), + visible: req.i18n.__("Visible"), + save: req.i18n.__("Save"), + }, + }, + }); + } + + async getEditTemplate(id: string | number, catalogId: string, req: ReqType): Promise<{ + type: string; + data: { + items?: { name: string; required: boolean; label: string }[]; + item?: NavItem; + labels?: Record; + }; + }> { + return { + type: "navigation.link", + data: { + items: [{ + label: req.i18n.__("Link"), + name: "link", + required: true, + }], + item: await this.find(id, catalogId), + labels: { + openInNewWindow: req.i18n.__("Open in a new window"), + visible: req.i18n.__("Visible"), + title: req.i18n.__("Title"), + save: req.i18n.__("Save"), + }, + }, + }; + } +} + +function createLegacyCatalogHost(): any { + return { + accessRightsHelper: { + registerToken: (): void => undefined, + }, + }; +} diff --git a/fixture/apps/navigation/NavigationCatalogTemplates.es.js b/fixture/apps/navigation/NavigationCatalogTemplates.es.js new file mode 100644 index 00000000..7a00b8db --- /dev/null +++ b/fixture/apps/navigation/NavigationCatalogTemplates.es.js @@ -0,0 +1,263 @@ +var b = { exports: {} }, f = {}; +var C; +function M() { + if (C) return f; + C = 1; + var n = /* @__PURE__ */ Symbol.for("react.transitional.element"), m = /* @__PURE__ */ Symbol.for("react.fragment"); + function c(i, s, t) { + var a = null; + if (t !== void 0 && (a = "" + t), s.key !== void 0 && (a = "" + s.key), "key" in s) { + t = {}; + for (var d in s) + d !== "key" && (t[d] = s[d]); + } else t = s; + return s = t.ref, { + $$typeof: n, + type: i, + key: a, + ref: s !== void 0 ? s : null, + props: t + }; + } + return f.Fragment = m, f.jsx = c, f.jsxs = c, f; +} +var N; +function U() { + return N || (N = 1, b.exports = M()), b.exports; +} +var e = U(); +const S = window.React.useEffect, p = window.React.useState, V = window.JSComponents.AddForm, y = window.UIComponents.Button, I = window.UIComponents.Checkbox, T = window.UIComponents.Input, v = window.UIComponents.Label, P = window.UIComponents.Select, $ = window.UIComponents.SelectContent, q = window.UIComponents.SelectItem, A = window.UIComponents.SelectTrigger, J = window.UIComponents.SelectValue, R = window.LucideReact.LoaderCircle, z = window.LucideReact.Plus, j = window.axios; +function G(n) { + return n.mode === "update" ? /* @__PURE__ */ e.jsx(O, { ...n }) : /* @__PURE__ */ e.jsx(D, { ...n }); +} +function D({ template: n, itemType: m, parentId: c, actions: i }) { + const [s, t] = p(!1), [a, d] = p(!1), [x, u] = p(!1), l = n.data, h = async (r) => { + try { + u(!0), (await j.post("", { + data: { + record: r, + parentId: c, + targetBlank: s, + visible: a, + _method: "select", + type: m + }, + _method: "createItem" + })).data && (i.close(), await i.reload(null)); + } catch (w) { + console.error(w); + } finally { + u(!1); + } + }; + return /* @__PURE__ */ e.jsxs("div", { className: "p-8", children: [ + /* @__PURE__ */ e.jsxs("div", { className: "flex gap-4 flex-col", children: [ + /* @__PURE__ */ e.jsxs("div", { className: `grid gap-4 ${l.items.length ? "" : "opacity-50 pointer-events-none"}`, children: [ + /* @__PURE__ */ e.jsx(v, { children: l.labels.selectTitle }), + /* @__PURE__ */ e.jsxs(P, { onValueChange: h, disabled: x, children: [ + /* @__PURE__ */ e.jsx(A, { className: "w-full max-w-[170px] cursor-pointer", children: /* @__PURE__ */ e.jsx(J, { placeholder: l.labels.selectTitle }) }), + /* @__PURE__ */ e.jsx($, { className: "z-[1003]", children: l.items.map((r) => /* @__PURE__ */ e.jsx(q, { value: r.id.toString(), children: r.name }, `${r.id}-${r.name}`)) }) + ] }) + ] }), + /* @__PURE__ */ e.jsx( + E, + { + labels: l.labels, + targetBlank: s, + visible: a, + onTargetBlankChange: t, + onVisibleChange: d + } + ) + ] }), + /* @__PURE__ */ e.jsx("div", { className: "mt-8", children: /* @__PURE__ */ e.jsx("span", { children: /* @__PURE__ */ e.jsx("b", { children: l.labels.OR }) }) }), + /* @__PURE__ */ e.jsxs(y, { className: "mt-8", onClick: () => i.openModelAdd(l.model), children: [ + /* @__PURE__ */ e.jsx(z, {}), + l.labels.createTitle + ] }) + ] }); +} +function O({ template: n, selectedItem: m, messages: c, actions: i }) { + const [s, t] = p(null), a = n.data.item, d = n.data.model ?? a.type ?? m?.type; + S(() => { + (async () => { + const l = await j.get(`${window.routePrefix}/model/${d}/edit/${a.modelId}?without_layout=true`); + t(l.data); + })().catch(console.error); + }, [a.modelId, d]); + const x = async (u, l, h) => { + const r = u[0]; + r.targetBlank = l, r.visible = h, r.treeId = a.id; + const w = await j.put("", { + type: a.type, + data: { record: r }, + modelId: a.modelId, + _method: "updateItem" + }); + i.close(), await i.reload(w.data.data); + }; + return s ? /* @__PURE__ */ e.jsx( + V, + { + page: s, + catalog: !0, + callback: x, + openNewWindowLabel: c["Open in a new window"], + visibleLable: c.Visible, + openNewWindow: a.targetBlank, + DnavVisible: a.visible, + isNavigation: !0 + } + ) : /* @__PURE__ */ e.jsx(R, { className: "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-8 animate-spin" }); +} +function W(n) { + return /* @__PURE__ */ e.jsx(L, { ...n, templateType: "group" }); +} +function Y(n) { + return /* @__PURE__ */ e.jsx(L, { ...n, templateType: "link" }); +} +function L({ + mode: n, + template: m, + parentId: c, + actions: i, + templateType: s +}) { + const t = m.data, [a, d] = p({}), [x, u] = p(!1), [l, h] = p(!1), [r, w] = p(!1); + S(() => { + if (t.item) { + d({ + name: t.item.name || "", + ...Object.fromEntries(t.items.map((o) => [ + o.name, + t.item ? t.item[o.name] : "" + ])) + }), u(t.item.targetBlank || !1), h(t.item.visible || !1); + return; + } + d({ + name: "", + ...Object.fromEntries(t.items.map((o) => [o.name, ""])) + }), u(!1), h(!1); + }, [t.item, t.items]); + const k = (o) => { + const { name: g, value: _ } = o.target; + d((F) => ({ ...F, [g]: _ })); + }, B = async (o) => { + o.preventDefault(), w(!0); + try { + if (n === "update") { + const g = await j.put("", { + type: t.item?.type, + modelId: t.item?.id, + data: { + ...t.item, + ...a, + targetBlank: x, + visible: l + }, + _method: "updateItem" + }); + i.close(), await i.reload(g.data.data); + return; + } + await j.post("", { + data: { + ...a, + targetBlank: x, + visible: l, + parentId: c, + type: s + }, + _method: "createItem" + }), i.close(), await i.reload(null); + } catch (g) { + console.error(g); + } finally { + w(!1); + } + }; + return /* @__PURE__ */ e.jsxs("div", { className: "p-8", children: [ + /* @__PURE__ */ e.jsxs("form", { className: "grid gap-6", id: "navigation-group-link-template", onSubmit: B, children: [ + /* @__PURE__ */ e.jsx( + E, + { + labels: t.labels, + targetBlank: x, + visible: l, + onTargetBlankChange: u, + onVisibleChange: h + } + ), + /* @__PURE__ */ e.jsxs("div", { className: "grid gap-4", children: [ + /* @__PURE__ */ e.jsx(v, { htmlFor: "name", children: t.labels.title }), + /* @__PURE__ */ e.jsx( + T, + { + required: !0, + value: a.name || "", + name: "name", + placeholder: t.labels.title, + onChange: k + } + ) + ] }), + t.items.map((o) => /* @__PURE__ */ e.jsxs("div", { className: "grid gap-4", children: [ + /* @__PURE__ */ e.jsx(v, { children: o.label }), + /* @__PURE__ */ e.jsx( + T, + { + required: o.required, + value: a[o.name] || "", + name: o.name, + placeholder: o.label, + onChange: k + } + ) + ] }, o.name)) + ] }), + /* @__PURE__ */ e.jsxs(y, { className: "mt-8 w-fit", form: "navigation-group-link-template", type: "submit", disabled: r, children: [ + t.labels.save, + r && /* @__PURE__ */ e.jsx(R, { className: "h-4 w-4 animate-spin ml-2" }) + ] }) + ] }); +} +function E({ + labels: n, + targetBlank: m, + visible: c, + onTargetBlankChange: i, + onVisibleChange: s +}) { + return /* @__PURE__ */ e.jsxs(e.Fragment, { children: [ + /* @__PURE__ */ e.jsxs("div", { className: "flex gap-4 items-center", children: [ + /* @__PURE__ */ e.jsx( + I, + { + id: "targetBlank", + checked: m, + onCheckedChange: (t) => i(!!t), + className: "cursor-pointer size-5" + } + ), + /* @__PURE__ */ e.jsx(v, { htmlFor: "targetBlank", children: n.openInNewWindow }) + ] }), + /* @__PURE__ */ e.jsxs("div", { className: "flex gap-4 items-center", children: [ + /* @__PURE__ */ e.jsx( + I, + { + id: "visible", + checked: c, + onCheckedChange: (t) => s(!!t), + className: "cursor-pointer size-5" + } + ), + /* @__PURE__ */ e.jsx(v, { htmlFor: "visible", children: n.visible }) + ] }) + ] }); +} +export { + W as NavigationGroupTemplate, + Y as NavigationLinkTemplate, + G as NavigationModelLinkTemplate +}; diff --git a/fixture/apps/navigation/NavigationCatalogTemplates.tsx b/fixture/apps/navigation/NavigationCatalogTemplates.tsx new file mode 100644 index 00000000..2122cb33 --- /dev/null +++ b/fixture/apps/navigation/NavigationCatalogTemplates.tsx @@ -0,0 +1,304 @@ +import React, {useEffect, useState} from "react"; +import AddForm from "@/components/add-form"; +import {Button} from "@/components/ui/button"; +import {Checkbox} from "@/components/ui/checkbox"; +import {Input} from "@/components/ui/input"; +import {Label} from "@/components/ui/label"; +import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"; +import type {AddCatalogProps, CatalogTemplateComponentProps} from "@/types"; +import {LoaderCircle, Plus} from "lucide-react"; +import axios from "@/lib/axios-compat"; + +export function NavigationModelLinkTemplate(props: CatalogTemplateComponentProps) { + if (props.mode === "update") { + return ; + } + + return ; +} + +function NavigationModelLinkCreateTemplate({template, itemType, parentId, actions}: CatalogTemplateComponentProps) { + const [targetBlank, setTargetBlank] = useState(false); + const [visible, setVisible] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const data = template.data; + + const handleSelect = async (value: string) => { + try { + setIsSubmitting(true); + const res = await axios.post("", { + data: { + record: value, + parentId, + targetBlank, + visible, + _method: "select", + type: itemType, + }, + _method: "createItem", + }); + if (res.data) { + actions.close(); + await actions.reload(null); + } + } catch (e) { + console.error(e); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+
+ + +
+ +
+
+ {data.labels.OR} +
+ +
+ ); +} + +function NavigationModelLinkEditTemplate({template, selectedItem, messages, actions}: CatalogTemplateComponentProps) { + const [addProps, setAddProps] = useState(null); + const item = template.data.item; + const model = template.data.model ?? item.type ?? selectedItem?.type; + + useEffect(() => { + const loadModelForm = async () => { + const res = await axios.get(`${window.routePrefix}/model/${model}/edit/${item.modelId}?without_layout=true`); + setAddProps(res.data); + }; + loadModelForm().catch(console.error); + }, [item.modelId, model]); + + const updateModelLink = async (record: any, targetBlank?: boolean, visible?: boolean) => { + const updatedRecord = record[0]; + updatedRecord.targetBlank = targetBlank; + updatedRecord.visible = visible; + updatedRecord.treeId = item.id; + + const res = await axios.put("", { + type: item.type, + data: {record: updatedRecord}, + modelId: item.modelId, + _method: "updateItem", + }); + + actions.close(); + await actions.reload(res.data.data); + }; + + if (!addProps) { + return ; + } + + return ( + + ); +} + +export function NavigationGroupTemplate(props: CatalogTemplateComponentProps) { + return ; +} + +export function NavigationLinkTemplate(props: CatalogTemplateComponentProps) { + return ; +} + +function NavigationGroupLinkTemplate({ + mode, + template, + parentId, + actions, + templateType, +}: CatalogTemplateComponentProps & { templateType: "group" | "link" }) { + const data = template.data; + const [formData, setFormData] = useState>({}); + const [targetBlank, setTargetBlank] = useState(false); + const [visible, setVisible] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (data.item) { + setFormData({ + name: data.item.name || "", + ...Object.fromEntries(data.items.map((item: { name: string }) => [ + item.name, + data.item ? data.item[item.name] : "", + ])), + }); + setTargetBlank(data.item.targetBlank || false); + setVisible(data.item.visible || false); + return; + } + + setFormData({ + name: "", + ...Object.fromEntries(data.items.map((item: { name: string }) => [item.name, ""])), + }); + setTargetBlank(false); + setVisible(false); + }, [data.item, data.items]); + + const handleChange = (e: React.ChangeEvent) => { + const {name, value} = e.target; + setFormData((prev) => ({...prev, [name]: value})); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + + try { + if (mode === "update") { + const res = await axios.put("", { + type: data.item?.type, + modelId: data.item?.id, + data: { + ...data.item, + ...formData, + targetBlank, + visible, + }, + _method: "updateItem", + }); + actions.close(); + await actions.reload(res.data.data); + return; + } + + await axios.post("", { + data: { + ...formData, + targetBlank, + visible, + parentId, + type: templateType, + }, + _method: "createItem", + }); + actions.close(); + await actions.reload(null); + } catch (e) { + console.error(e); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+ ); +} + +function NavigationFlags({ + labels, + targetBlank, + visible, + onTargetBlankChange, + onVisibleChange, +}: { + labels: Record; + targetBlank: boolean; + visible: boolean; + onTargetBlankChange: (value: boolean) => void; + onVisibleChange: (value: boolean) => void; +}) { + return ( + <> +
+ onTargetBlankChange(!!checked)} + className="cursor-pointer size-5" + /> + +
+
+ onVisibleChange(!!checked)} + className="cursor-pointer size-5" + /> + +
+ + ); +} diff --git a/fixture/apps/navigation/NavigationModel.ts b/fixture/apps/navigation/NavigationModel.ts new file mode 100644 index 00000000..ffba33ae --- /dev/null +++ b/fixture/apps/navigation/NavigationModel.ts @@ -0,0 +1,45 @@ +import {DataTypes, Sequelize} from "sequelize"; + +export const navigationModelName = "Navigation"; + +export async function installNavigationSequelizeModel( + orm: Sequelize, + modelName: string = navigationModelName, + sync: boolean = false +): Promise { + let model = Object.values(orm.models).find( + (registeredModel) => registeredModel.name.toLowerCase() === modelName.toLowerCase() + ); + + if (!model) { + model = orm.define(modelName, { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + label: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, + tree: { + type: DataTypes.JSON, + allowNull: false, + }, + }, { + tableName: modelName.toLowerCase(), + timestamps: true, + }); + } + + if (sync) { + await model.sync(); + } +} + +export interface NavigationModel { + id?: number; + label: string; + tree: Record; +} diff --git a/fixture/apps/navigation/NavigationTypes.ts b/fixture/apps/navigation/NavigationTypes.ts new file mode 100644 index 00000000..a77b6586 --- /dev/null +++ b/fixture/apps/navigation/NavigationTypes.ts @@ -0,0 +1,23 @@ +export interface NavigationItemConfig { + model: string; + title: string; + urlPath: string | ((value: any) => string); +} + +export interface NavigationGroupField { + name: string; + required: boolean; + label: string; +} + +export interface NavigationAppConfig { + model?: string; + routePrefix?: string; + componentFile?: string; + devComponentUrl?: string; + sections: string[]; + groupField: NavigationGroupField[]; + allowContentInGroup?: boolean; + items: NavigationItemConfig[]; + movingGroupsRootOnly?: boolean; +} diff --git a/fixture/apps/navigation/navigationConfig.ts b/fixture/apps/navigation/navigationConfig.ts new file mode 100644 index 00000000..66ac1535 --- /dev/null +++ b/fixture/apps/navigation/navigationConfig.ts @@ -0,0 +1,37 @@ +import {routePrefix} from "../../adminizerConfig"; +import type {NavigationAppConfig} from "./NavigationTypes"; + +export const navigationAppConfig: NavigationAppConfig = { + routePrefix, + items: [ + { + title: "Category", + model: "Category", + urlPath: "/catalog/category/${data.record.slug}", + }, + { + title: "Example", + // `Full` is the Adminizer resource name; its physical ORM model is `example`. + model: "Full", + urlPath: `${routePrefix}/model/Full/\${data.record.id}`, + }, + ], + sections: ["header", "footer"], + groupField: [ + { + name: "link", + label: "Ссылка", + required: false, + }, + { + name: "test_field", + label: "Test", + required: false, + }, + { + name: "test_feild2", + label: "Test2", + required: false, + }, + ], +}; diff --git a/fixture/apps/navigation/vite.config.module.ts b/fixture/apps/navigation/vite.config.module.ts new file mode 100644 index 00000000..eb34aacd --- /dev/null +++ b/fixture/apps/navigation/vite.config.module.ts @@ -0,0 +1,74 @@ +import {defineConfig} from "vite"; +import react from "@vitejs/plugin-react"; +import path from "path"; +import {viteExternalsPlugin} from "vite-plugin-externals"; + +const uiExternals = { + react: "React", + "react-dom": "ReactDOM", + "lucide-react": "LucideReact", + sonner: "sonner", + "@/components/add-form": ["JSComponents", "AddForm"], + "@/components/add-form.tsx": ["JSComponents", "AddForm"], + "@/components/ui/avatar": "UIComponents", + "@/components/ui/badge": "UIComponents", + "@/components/ui/breadcrumb": "UIComponents", + "@/components/ui/button": "UIComponents", + "@/components/ui/calendar": "UIComponents", + "@/components/ui/card": "UIComponents", + "@/components/ui/checkbox": "UIComponents", + "@/components/ui/collapsible": "UIComponents", + "@/components/ui/command": "UIComponents", + "@/components/ui/context-menu": "UIComponents", + "@/components/ui/dialog": "UIComponents", + "@/components/ui/dialog-stack": "UIComponents", + "@/components/ui/dropdown-menu": "UIComponents", + "@/components/ui/input": "UIComponents", + "@/components/ui/label": "UIComponents", + "@/components/ui/menubar": "UIComponents", + "@/components/ui/pagination": "UIComponents", + "@/components/ui/popover": "UIComponents", + "@/components/ui/select": "UIComponents", + "@/components/ui/separator": "UIComponents", + "@/components/ui/sheet": "UIComponents", + "@/components/ui/sidebar": "UIComponents", + "@/components/ui/skeleton": "UIComponents", + "@/components/ui/slider": "UIComponents", + "@/components/ui/sonner": "UIComponents", + "@/components/ui/switch": "UIComponents", + "@/components/ui/table": "UIComponents", + "@/components/ui/textarea": "UIComponents", + "@/components/ui/tooltip": "UIComponents", + '@/lib/axios-compat': 'axios', +}; + +export default defineConfig({ + define: { + "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "production"), + }, + plugins: [ + react(), + viteExternalsPlugin(uiExternals), + ], + build: { + outDir: path.resolve(import.meta.dirname, ""), + emptyOutDir: false, + lib: { + entry: path.resolve(import.meta.dirname, "NavigationCatalogTemplates"), + name: "NavigationCatalogTemplates", + formats: ["es"], + fileName: (format) => `NavigationCatalogTemplates.${format}.js`, + }, + rollupOptions: { + external: [ + "tailwindcss", + ...Object.keys(uiExternals), + ], + }, + }, + resolve: { + alias: { + "@": path.resolve(import.meta.dirname, "../../../src/assets/js"), + }, + }, +}); diff --git a/fixture/apps/notification-sender/NotificationSender.es.js b/fixture/apps/notification-sender/NotificationSender.es.js new file mode 100644 index 00000000..56e33853 --- /dev/null +++ b/fixture/apps/notification-sender/NotificationSender.es.js @@ -0,0 +1,102 @@ +var x = { exports: {} }, c = {}; +var h; +function g() { + if (h) return c; + h = 1; + var d = /* @__PURE__ */ Symbol.for("react.transitional.element"), l = /* @__PURE__ */ Symbol.for("react.fragment"); + function a(i, t, n) { + var r = null; + if (n !== void 0 && (r = "" + n), t.key !== void 0 && (r = "" + t.key), "key" in t) { + n = {}; + for (var o in t) + o !== "key" && (n[o] = t[o]); + } else n = t; + return t = n.ref, { + $$typeof: d, + type: i, + key: r, + ref: t !== void 0 ? t : null, + props: n + }; + } + return c.Fragment = l, c.jsx = a, c.jsxs = a, c; +} +var w; +function R() { + return w || (w = 1, x.exports = g()), x.exports; +} +var e = R(); +const u = window.React.useState, I = window.UIComponents.Button, T = window.UIComponents.Select, k = window.UIComponents.SelectContent, j = window.UIComponents.SelectItem, N = window.UIComponents.SelectTrigger, U = window.UIComponents.SelectValue, b = window.axios, E = window.UIComponents.Textarea, p = window.UIComponents.Label, L = window.UIComponents.Checkbox, f = window.sonner.toast, _ = window.LucideReact; +function A({ data: d }) { + const [l, a] = u(""), [i, t] = u(""), [n, r] = u(!1), [o, v] = u(!1), S = async (s) => { + s.preventDefault(), r(!0); + try { + const m = { + message: i, + ...o ? { sendToAll: !0 } : { userId: l } + }, C = await b.post("", m); + console.log(C.data), f.success("Сообщение успешно отправлено!"); + } catch (m) { + console.error(m), f.error("Ошибка при отправке сообщения."); + } finally { + r(!1); + } + }; + return /* @__PURE__ */ e.jsx(e.Fragment, { children: /* @__PURE__ */ e.jsx("div", { className: "grid gap-4", children: /* @__PURE__ */ e.jsxs("form", { onSubmit: S, className: "flex flex-col gap-4 max-w-[400px]", children: [ + /* @__PURE__ */ e.jsxs("div", { className: "flex items-center gap-2", children: [ + /* @__PURE__ */ e.jsx( + L, + { + id: "send-to-all", + checked: o, + onCheckedChange: (s) => { + v(s), s && a(""); + } + } + ), + /* @__PURE__ */ e.jsx(p, { htmlFor: "send-to-all", children: "Всем пользователям" }) + ] }), + /* @__PURE__ */ e.jsxs("div", { children: [ + /* @__PURE__ */ e.jsx(p, { htmlFor: "user-select", className: "block mb-2 font-medium", children: "Выберите пользователя:" }), + /* @__PURE__ */ e.jsxs( + T, + { + onValueChange: (s) => a(s), + value: l, + disabled: o, + children: [ + /* @__PURE__ */ e.jsx(N, { id: "user-select", className: "w-full", children: /* @__PURE__ */ e.jsx(U, { placeholder: "-- Выберите --" }) }), + /* @__PURE__ */ e.jsx(k, { children: d?.users?.length > 0 ? d.users.map((s) => /* @__PURE__ */ e.jsx(j, { value: s.id.toString(), children: s.fullName }, s.id)) : /* @__PURE__ */ e.jsx(j, { value: "", disabled: !0, children: "Пользователей нет" }) }) + ] + } + ) + ] }), + /* @__PURE__ */ e.jsxs("div", { children: [ + /* @__PURE__ */ e.jsx(p, { htmlFor: "message-input", className: "block mb-2 font-medium", children: "Сообщение:" }), + /* @__PURE__ */ e.jsx( + E, + { + id: "message-input", + value: i, + onChange: (s) => t(s.target.value), + placeholder: "Введите сообщение..." + } + ) + ] }), + /* @__PURE__ */ e.jsxs("div", { className: "flex gap-2 items-center", children: [ + /* @__PURE__ */ e.jsx( + I, + { + type: "submit", + className: "w-fit", + disabled: n || !o && !l || !i.trim(), + children: n ? "Отправка..." : "Отправить" + } + ), + n && /* @__PURE__ */ e.jsx(_.Loader2, { className: "w-6 h-6 animate-spin" }) + ] }) + ] }) }) }); +} +export { + A as default +}; diff --git a/modules/test/ComponentB.tsx b/fixture/apps/notification-sender/NotificationSender.tsx similarity index 95% rename from modules/test/ComponentB.tsx rename to fixture/apps/notification-sender/NotificationSender.tsx index 005273ee..6a5d448a 100644 --- a/modules/test/ComponentB.tsx +++ b/fixture/apps/notification-sender/NotificationSender.tsx @@ -7,11 +7,10 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import axios from "axios"; +import axios from "@/lib/axios-compat"; import {Textarea} from "@/components/ui/textarea"; import {Label} from "@/components/ui/label"; import {Checkbox} from "@/components/ui/checkbox"; -import {Toaster} from "@/components/ui/sonner"; import {toast} from "sonner"; import * as LucideReact from 'lucide-react' @@ -24,7 +23,7 @@ interface AppContentProps { }; } -export default function allUsersNotification({data}: AppContentProps) { +export default function NotificationSender({data}: AppContentProps) { const [selectedUser, setSelectedUser] = useState(""); const [inputMessage, setInputMessage] = useState(""); const [isLoading, setIsLoading] = useState(false); @@ -53,7 +52,6 @@ export default function allUsersNotification({data}: AppContentProps) { return ( <> -
@@ -122,4 +120,4 @@ export default function allUsersNotification({data}: AppContentProps) {
); -} \ No newline at end of file +} diff --git a/fixture/apps/notification-sender/NotificationSenderApp.ts b/fixture/apps/notification-sender/NotificationSenderApp.ts new file mode 100644 index 00000000..cd40f79a --- /dev/null +++ b/fixture/apps/notification-sender/NotificationSenderApp.ts @@ -0,0 +1,118 @@ +import path from "path"; +import { + AbstractAdminizerApp, + AdminpanelIcon, + AppSetupContext, + User, +} from "../../../dist"; + +interface NotificationSenderAppConfig { + route: string; + sidebarId: string; + title: string; + icon: AdminpanelIcon; + section: string; + componentFile: string; + devComponentUrl: string; +} + +export class NotificationSenderApp extends AbstractAdminizerApp { + readonly name = "notification-sender"; + readonly version = "1.0.0"; + declare readonly config: NotificationSenderAppConfig; + + constructor(config: Partial = {}) { + super(); + this.config = { + route: "/notification-sender", + sidebarId: "notification-sender", + title: "Notification Sender", + icon: "360", + section: "Platform", + componentFile: path.resolve(import.meta.dirname, "NotificationSender.es.js"), + devComponentUrl: "/fixture/apps/notification-sender/NotificationSender.tsx", + ...config, + }; + } + + setup(ctx: AppSetupContext): void { + ctx.modelAccess({ + id: "users", + models: ["UserAP"], + }); + + const moduleComponent = ctx.asset({ + id: "component", + filePath: this.config.componentFile, + devUrl: this.config.devComponentUrl, + }); + + const pageUrl = ctx.controller({ + id: "page", + method: "get", + route: this.config.route, + middleware: this.renderModule(moduleComponent), + policies: [{type: "auth", mode: "ui"}], + }); + + ctx.controller({ + id: "send-notification", + method: "post", + route: this.config.route, + middleware: this.sendNotification, + policies: [{type: "auth", mode: "api"}], + }); + + ctx.config({ + navbar: { + additionalLinks: [{ + id: this.config.sidebarId, + link: pageUrl, + type: "self", + title: this.config.title, + icon: this.config.icon, + section: this.config.section, + }], + }, + }); + } + + private renderModule(moduleComponent: string): MiddlewareType { + return async (req, res) => { + const users = await req.runtime.models.get("UserAP").find({}); + + return req.Inertia.render({ + component: "module", + props: { + moduleComponent, + data: { + users, + }, + }, + }); + }; + } + + private sendNotification: MiddlewareType = async (req, res) => { + const rawUserId = req.body.userId; + const userId = req.body.sendToAll ? undefined : Number(rawUserId); + + if (!req.body.sendToAll && (!Number.isInteger(userId) || userId <= 0)) { + return res.status(400).json({ + error: "Invalid userId", + }); + } + + await req.runtime.notifications.send({ + title: "User message", + message: req.body.message, + notificationClass: "general", + channel: "", + ...(userId ? {userId} : {}), + }); + + return res.json({ + data: req.body, + }); + }; +} diff --git a/modules/test/vite.config.module.ts b/fixture/apps/notification-sender/vite.config.module.ts similarity index 91% rename from modules/test/vite.config.module.ts rename to fixture/apps/notification-sender/vite.config.module.ts index 9e4801b8..f23f9b58 100644 --- a/modules/test/vite.config.module.ts +++ b/fixture/apps/notification-sender/vite.config.module.ts @@ -44,18 +44,17 @@ export default defineConfig({ '@/components/ui/table': 'UIComponents', '@/components/ui/textarea': 'UIComponents', '@/components/ui/tooltip': 'UIComponents', + '@/lib/axios-compat': 'axios', }), ], build: { outDir: path.resolve(import.meta.dirname, ''), - // outDir: path.resolve(import.meta.dirname, 'dist/assets'), emptyOutDir: false, lib: { - // Library entrance point - entry: path.resolve(import.meta.dirname, 'ComponentB'), - name: 'ComponentB', + entry: path.resolve(import.meta.dirname, 'NotificationSender'), + name: 'NotificationSender', formats: ['es'], - fileName: (format) => `ComponentB.${format}.js`, + fileName: (format) => `NotificationSender.${format}.js`, }, rollupOptions: { external: [ @@ -89,12 +88,13 @@ export default defineConfig({ '@/components/ui/table', '@/components/ui/textarea', '@/components/ui/tooltip', + '@/lib/axios-compat', ], }, }, resolve: { alias: { - '@': path.resolve(import.meta.dirname, '../../src/assets/js'), + '@': path.resolve(import.meta.dirname, '../../../src/assets/js'), }, }, }); diff --git a/fixture/apps/project-graph/ProjectGraphApp.ts b/fixture/apps/project-graph/ProjectGraphApp.ts new file mode 100644 index 00000000..4cf5b93e --- /dev/null +++ b/fixture/apps/project-graph/ProjectGraphApp.ts @@ -0,0 +1,267 @@ +/** + * Project → Task → Message: the reference implementation of `accessGraph`, packaged as + * an Adminizer app so the whole demo can be switched off in one line. + * + * Everything the demo owns is declared in `setup()` and therefore disappears again when + * the app is disabled: the three panel models and their CRUD tokens, the graph itself + * (`accessGraph.project`), the bypass token and the add/edit routes. + * + * What the app cannot own is the tables: an app may register a model with the panel, but + * it cannot add tables to the host's ORM connection. The host installs them before + * enabling the app — `installProjectGraphSequelizeModels()` for Sequelize, + * `projectGraphTypeOrmModels` in the DataSource entity list for TypeORM. + */ +import {AbstractAdminizerApp, AppSetupContext, AppRuntime, type AppConfigPatch} from "../../../dist"; +// The built-in add/edit controllers. Model routes are bound at init from `config.models`, +// so a model that joins the panel later (as app models do) has a list, view and remove +// route from the generic `/model/:modelResourceName/...` handlers, but no add/edit route. +// Registering them as app controllers keeps them tied to the app lifecycle. +import _add from "../../../dist/controllers/add.js"; +import _edit from "../../../dist/controllers/edit.js"; +import {projectGraphModelNames} from "./ProjectGraphModels"; +import {seedProjectGraph} from "./projectGraphSeed"; + +export const PROJECT_GRAPH_BYPASS_TOKEN = "project-admin"; + +export interface ProjectGraphAppConfig { + /** ORM adapter the models were installed on. */ + adapter?: string; + /** Global group of the demo users, granted the models' CRUD tokens while seeding. */ + baseGroup: string; + /** + * Its holders see every project of the graph, membership or not. Granted to nobody by + * default — assign it to a group in the panel to try it out. + */ + bypassToken: string; + /** + * Sidebar section the demo models are grouped under (and the rights department of + * the bypass token) — the demo stays out of the host's own model list. + */ + section: string; +} + +export class ProjectGraphApp extends AbstractAdminizerApp { + readonly name = "project-graph"; + readonly version = "1.0.0"; + declare readonly config: ProjectGraphAppConfig; + + /** Captured while the app is enabled; the seeding step is the only user of it. */ + private runtime?: AppRuntime; + + constructor(config: Partial = {}) { + super(); + this.config = { + baseGroup: "Users", + bypassToken: PROJECT_GRAPH_BYPASS_TOKEN, + section: "Project graph", + ...config, + }; + } + + /** + * Writes the demo projects, memberships, tasks and messages. Called by the host + * rather than on `app:enabled`, because the memberships reference the host's own + * seeded users — who exist only once the host says so. Idempotent. + */ + async seedDemoData(): Promise { + if (!this.runtime) { + throw new Error(`App "${this.name}" must be enabled before seeding its demo data`); + } + await seedProjectGraph(this.runtime.models, {baseGroup: this.config.baseGroup}); + } + + setup(ctx: AppSetupContext): void { + const {project, member, task, message} = projectGraphModelNames; + + for (const modelName of [project, member, task, message]) { + ctx.model({name: modelName, adapter: this.config.adapter}); + // Model CRUD tokens are registered at boot from `config.models`, which the app + // patches only afterwards — so the app registers its own. An unregistered token + // is denied to everyone but administrators. + this.registerModelTokens(ctx, modelName); + } + + // User and Group are needed by the seeding step only. + ctx.modelAccess({models: [project, member, task, message, "User", "Group"]}); + + ctx.accessRight({ + id: this.config.bypassToken, + name: "Project graph bypass", + description: "See every project of the graph, membership or not", + department: this.config.section, + }); + + ctx.config({ + models: this.modelsConfig(), + accessGraph: { + /** + * Membership is declared once, at the root: a user reaches a Project through a + * ProjectMember row, and the row's `group` — a regular Group record used as a + * per-project role — decides which actions that membership counts for. Task and + * Message carry no rule of their own; they inherit the reach down their parent + * edges, Message transitively through Task. + */ + project: { + root: project, + membership: {through: member, via: "user", group: "group"}, + include: { + [task]: {parent: "project"}, // Task.project → Project (the root) + [message]: {parent: "task"}, // Message.task → Task → Project + }, + bypassToken: this.config.bypassToken, + // Optional stages, both leave the declaration above unchanged: + // graphRootField: {[message]: "projectId"}, // denormalized root id, one-step filter + // pushdown: true, // one nested-subquery SQL per read + }, + }, + }); + + for (const modelName of [project, member, task, message]) { + ctx.controller({ + id: `add-${modelName}`, + method: "all", + route: `/model/${modelName}/add`, + middleware: _add as MiddlewareType, + policies: [ + {type: "auth", mode: "ui"}, + {type: "permission", token: `create-${modelName}-model`.toLowerCase(), mode: "ui"}, + ], + }); + ctx.controller({ + id: `edit-${modelName}`, + method: "all", + route: `/model/${modelName}/edit/:id`, + middleware: _edit as MiddlewareType, + policies: [ + {type: "auth", mode: "ui"}, + {type: "permission", token: `update-${modelName}-model`.toLowerCase(), mode: "ui"}, + ], + }); + } + + ctx.listener("app:enabled", (payload: {appName?: string}, runtime: AppRuntime) => { + if (payload?.appName === this.name) { + this.runtime = runtime; + } + }); + ctx.listener("app:disabled", (payload: {appName?: string}) => { + if (payload?.appName === this.name) { + this.runtime = undefined; + } + }); + } + + private registerModelTokens(ctx: AppSetupContext, modelName: string): void { + const department = `Model ${modelName}`; + const actions: [string, string][] = [ + ["create", "Create"], + ["read", "Read"], + ["update", "Update"], + ["delete", "Delete"], + ]; + for (const [verb, name] of actions) { + ctx.accessRight({ + id: `${verb}-${modelName}-model`.toLowerCase(), + name, + description: `Access to ${verb} records of ${modelName}`, + department, + }); + } + } + + // Annotated so the object literal is contextually typed: without it `type: "string"` + // widens to `string` and stops selecting a member of the ModelFieldConfig union. + private modelsConfig(): NonNullable { + const {project, member, task, message} = projectGraphModelNames; + const hideTimestamps = { + createdAt: {visible: false}, + updatedAt: {visible: false}, + }; + // Keeps the demo in its own sidebar group instead of the host's "Platform" list + const navbar = {section: this.config.section}; + + return { + [project]: { + title: "Projects", + model: project.toLowerCase(), + displayName: "name", + icon: "workspaces", + navbar, + fields: { + ...hideTimestamps, + name: {title: "Name", type: "string", required: true}, + description: {title: "Description", type: "text"}, + }, + }, + [task]: { + title: "Tasks", + model: task.toLowerCase(), + displayName: "title", + icon: "task_alt", + navbar, + fields: { + ...hideTimestamps, + title: {title: "Title", type: "string", required: true}, + status: { + title: "Status", + type: "select", + isIn: {open: "Open", in_progress: "In progress", done: "Done"}, + }, + // The graph edge. Its picker needs no configuration: it reads Project + // through the same DataAccessor, so only the user's own projects are + // offered — and a foreign one is refused on save. + project: { + title: "Project", + required: true, + displayModifier: (data: any) => data?.name || "", + }, + }, + }, + [message]: { + title: "Messages", + model: message.toLowerCase(), + displayName: "text", + icon: "chat", + navbar, + fields: { + ...hideTimestamps, + text: {title: "Message", type: "text", required: true}, + task: { + title: "Task", + required: true, + displayModifier: (data: any) => data?.title || "", + }, + author: { + title: "Author", + displayModifier: (data: any) => data?.login || "", + }, + }, + }, + // The source of the memberships, deliberately outside the graph: only + // administrators hold its tokens, so only they hand out project access. + [member]: { + title: "Project members", + model: member.toLowerCase(), + icon: "group", + navbar, + fields: { + ...hideTimestamps, + project: { + title: "Project", + required: true, + displayModifier: (data: any) => data?.name || "", + }, + user: { + title: "User", + required: true, + displayModifier: (data: any) => data?.login || "", + }, + group: { + title: "Role (group)", + displayModifier: (data: any) => data?.name || "", + }, + }, + }, + }; + } +} diff --git a/fixture/apps/project-graph/ProjectGraphModels.ts b/fixture/apps/project-graph/ProjectGraphModels.ts new file mode 100644 index 00000000..51ae70aa --- /dev/null +++ b/fixture/apps/project-graph/ProjectGraphModels.ts @@ -0,0 +1,102 @@ +/** + * Sequelize models of the Project → Task → Message access-graph demo. + * + * Installed by the host before the app is enabled, exactly like the media manager + * models: an app can register a model with the panel, but it cannot add tables to + * someone else's ORM connection. + */ +import { + DataTypes, + Model, + ModelAttributes, + ModelStatic, + Sequelize, +} from "sequelize"; + +export const projectGraphModelNames = { + project: "Project", + member: "ProjectMember", + task: "Task", + message: "Message", +} as const; + +export interface ProjectGraphSystemModelNames { + /** Host model behind the canonical `User` resource. */ + user?: string; + /** Host model behind the canonical `Group` resource. */ + group?: string; +} + +export async function installProjectGraphSequelizeModels( + orm: Sequelize, + sync: boolean = false, + systemModels: ProjectGraphSystemModelNames = {} +): Promise { + const UserModel = orm.model(systemModels.user ?? "UserAP"); + const GroupModel = orm.model(systemModels.group ?? "GroupAP"); + + const Project = getOrDefine(orm, projectGraphModelNames.project, { + id: {type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true}, + name: {type: DataTypes.STRING, allowNull: false}, + description: DataTypes.TEXT, + }); + + // Membership rows of the graph root: who belongs to which project, in which role. + const ProjectMember = getOrDefine(orm, projectGraphModelNames.member, { + id: {type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true}, + projectId: {type: DataTypes.INTEGER, allowNull: false}, + userId: {type: DataTypes.INTEGER, allowNull: false}, + groupId: {type: DataTypes.INTEGER, allowNull: true}, + }); + + const Task = getOrDefine(orm, projectGraphModelNames.task, { + id: {type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true}, + title: {type: DataTypes.STRING, allowNull: false}, + status: {type: DataTypes.STRING, defaultValue: "open"}, + projectId: {type: DataTypes.INTEGER, allowNull: false}, + }); + + const Message = getOrDefine(orm, projectGraphModelNames.message, { + id: {type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true}, + text: {type: DataTypes.TEXT, allowNull: false}, + taskId: {type: DataTypes.INTEGER, allowNull: false}, + authorId: {type: DataTypes.INTEGER, allowNull: true}, + }); + + if (!ProjectMember.associations.project) { + ProjectMember.belongsTo(Project, {as: "project", foreignKey: "projectId"}); + ProjectMember.belongsTo(UserModel, {as: "user", foreignKey: "userId"}); + // The per-project role: a regular Group record whose tokens decide which + // actions this membership counts for. + ProjectMember.belongsTo(GroupModel, {as: "group", foreignKey: "groupId"}); + } + // The graph edges. Task hangs directly off the root, Message reaches it through Task. + if (!Task.associations.project) { + Task.belongsTo(Project, {as: "project", foreignKey: "projectId"}); + } + if (!Message.associations.task) { + Message.belongsTo(Task, {as: "task", foreignKey: "taskId"}); + Message.belongsTo(UserModel, {as: "author", foreignKey: "authorId"}); + } + + if (sync) { + await Project.sync(); + await ProjectMember.sync(); + await Task.sync(); + await Message.sync(); + } +} + +function getOrDefine( + orm: Sequelize, + name: string, + attributes: ModelAttributes> +): ModelStatic { + const existing = Object.values(orm.models).find( + (model) => model.name.toLowerCase() === name.toLowerCase() + ); + return existing ?? orm.define(name, attributes, { + tableName: name.toLowerCase(), + timestamps: true, + }); +} diff --git a/fixture/apps/project-graph/ProjectGraphTypeOrmModels.ts b/fixture/apps/project-graph/ProjectGraphTypeOrmModels.ts new file mode 100644 index 00000000..5ae26b7c --- /dev/null +++ b/fixture/apps/project-graph/ProjectGraphTypeOrmModels.ts @@ -0,0 +1,109 @@ +/** + * TypeORM twin of fixture/models/sequelize/ProjectGraph.ts — the same + * Project → Task → Message chain with ProjectMember carrying the membership. + * The graph declaration (fixture/accessGraph.ts) is ORM-agnostic and covers both. + */ +import {EntitySchema} from "typeorm"; + +const timestamps = { + createdAt: { + type: "datetime" as const, + createDate: true, + }, + updatedAt: { + type: "datetime" as const, + updateDate: true, + }, +}; + +export const ProjectTypeOrm = new EntitySchema>({ + name: "Project", + tableName: "project", + columns: { + id: {type: "integer", primary: true, generated: true}, + name: {type: "varchar", nullable: false}, + description: {type: "text", nullable: true}, + ...timestamps, + }, +}); + +export const ProjectMemberTypeOrm = new EntitySchema>({ + name: "ProjectMember", + tableName: "projectmember", + columns: { + id: {type: "integer", primary: true, generated: true}, + ...timestamps, + }, + relations: { + project: { + type: "many-to-one", + target: "Project", + joinColumn: {name: "projectId"}, + nullable: false, + }, + user: { + type: "many-to-one", + target: "UserAP", + joinColumn: {name: "userId"}, + nullable: false, + }, + group: { + type: "many-to-one", + target: "GroupAP", + joinColumn: {name: "groupId"}, + nullable: true, + }, + }, +}); + +export const TaskTypeOrm = new EntitySchema>({ + name: "Task", + tableName: "task", + columns: { + id: {type: "integer", primary: true, generated: true}, + title: {type: "varchar", nullable: false}, + status: {type: "varchar", nullable: true, default: "open"}, + ...timestamps, + }, + relations: { + // The graph edge: Task.project → Project (the root itself) + project: { + type: "many-to-one", + target: "Project", + joinColumn: {name: "projectId"}, + nullable: false, + }, + }, +}); + +export const MessageTypeOrm = new EntitySchema>({ + name: "Message", + tableName: "message", + columns: { + id: {type: "integer", primary: true, generated: true}, + text: {type: "text", nullable: false}, + ...timestamps, + }, + relations: { + // The graph edge: Message.task → Task, which reaches Project transitively + task: { + type: "many-to-one", + target: "Task", + joinColumn: {name: "taskId"}, + nullable: false, + }, + author: { + type: "many-to-one", + target: "UserAP", + joinColumn: {name: "authorId"}, + nullable: true, + }, + }, +}); + +export const projectGraphTypeOrmModels = [ + ProjectTypeOrm, + ProjectMemberTypeOrm, + TaskTypeOrm, + MessageTypeOrm, +]; diff --git a/fixture/apps/project-graph/README.md b/fixture/apps/project-graph/README.md new file mode 100644 index 00000000..de6a48c6 --- /dev/null +++ b/fixture/apps/project-graph/README.md @@ -0,0 +1,163 @@ +# Project graph — a hands-on `accessGraph` playground + +This app exists for one reason: **to try record-level access over a model graph by hand, in a +running panel**, instead of reading about it in +[docs/AccessRights/user-owned-records.md](../../../docs/AccessRights/user-owned-records.md). + +It implements the chain the documentation uses as its example — + +``` +Project ──< Task ──< Message + ▲ + └── ProjectMember (user, role group) ← membership is granted here, once +``` + +— and seeds enough data that every rule of the graph can be observed by logging in as a +different user and clicking around. Everything is packaged as an Adminizer app, so the whole +demo can be removed from the fixture with one environment variable. + +--- + +## Run it + +```bash +npm run start:seed # Sequelize (default) +npm run start:typeorm:seed # the same demo on TypeORM +``` + +Then open and log in as one of the users below. The demo +models live in their own **Project graph** group in the sidebar, not mixed into the host's model +list — the group name is the app's `section` option. + +To take the demo out completely — no models, no graph, no menu entries, no routes: + +```bash +ENABLE_PROJECT_GRAPH=false npm run start:seed +``` + +> Seeding drops and recreates the fixture databases. `npm start` (without `:seed`) keeps the +> existing data, but then the demo rows are only there if a seeded run created them before. + +--- + +## Who is who + +| Login / password | Apollo | Borealis | What they should see | +| --- | --- | --- | --- | +| `user1` | **editor** | — | Only Apollo, its 2 tasks and 3 messages. Full CRUD inside Apollo. | +| `user2` | **viewer** | **editor** | Both projects and everything under them, but read-only in Apollo. | +| `user3` | — | **viewer** | Only Borealis, read-only. | +| `pass` | — | — | The sections are in the menu, and they are empty. | +| `admin` | — | — | Administrator: bypasses the graph entirely, sees all 4 tasks. | + +The roles are ordinary `Group` records (`Project editors`, `Project viewers`) referenced by the +`group` column of a membership row. They grant nothing globally — they only decide *which +actions* a membership counts for. The global group `Users` carries the editor token set as the +upper bound (plus `access-to-adminpanel`, without which the panel would refuse the login itself). + +Seeded content: projects **Apollo** and **Borealis**, two tasks each, 6 messages in total. + +--- + +## What to click, and what it proves + +Log in as **`user1`** (Apollo editor): + +1. **Tasks** → 2 rows, both Apollo. *The graph filters a model that has no relation to the + membership at all — `Task` inherits it from `Project`.* +2. **Messages** → 3 rows. *Two hops: `Message.task → Task.project → Project`. Nothing on + `Message` mentions projects or memberships.* +3. **Tasks → Add** → the *Project* dropdown offers **Apollo only**. *Association pickers read + their model through the same `DataAccessor`, so they are narrowed for free.* +4. Try to reach a Borealis task by URL: `/adminizer/model/Task/edit/3` → not found. *The filter + is applied to the query, not to the rendering.* + +Log in as **`user2`** (viewer in Apollo, editor in Borealis): + +5. **Tasks** → all 4 rows, from both projects. Edit a **Borealis** task inline → saved. + Edit an **Apollo** task inline → `404`. *Same user, same model, same token — the per-project + role decides. `Project viewers` carries `read-task-model` only, so the Apollo membership does + not count for an update.* + +Log in as **`user3`** (Borealis viewer): + +6. **Tasks → Add** offers Borealis (they may read it), but saving is refused. *Pickers are + narrowed by read access; the write path re-validates the chosen parent for the action's verb.* + +Log in as **`admin`**: + +7. **Project members** is visible only here. *It is deliberately outside the graph: no group in + the seed carries its CRUD tokens, so handing out project access stays an admin operation.* +8. Add a membership row for `pass`, pick a role, log back in as `pass` → the projects appear. + *Nothing else changes: no config edit, no restart.* +9. Give a **global** group the `project-admin` token (Groups → tokens, department *Project + graph*) and put a user in it → that user sees the whole graph unfiltered, membership or not. + *That is the graph's `bypassToken`. It is matched against the user's own groups only — + putting it on a membership role such as `Project viewers` changes nothing, which is the + point: a role must not be able to widen its own project boundary.* + +--- + +## How the app is put together + +| File | Role | +| --- | --- | +| `ProjectGraphApp.ts` | The app itself: registers the 4 models, their CRUD tokens, the config patch (model UI, sidebar section **and** `accessGraph`), the bypass token, the add/edit routes and the seeding entry point. | +| `ProjectGraphModels.ts` | Sequelize models + `installProjectGraphSequelizeModels()`. | +| `ProjectGraphTypeOrmModels.ts` | The same entities as TypeORM `EntitySchema`s. | +| `projectGraphSeed.ts` | Demo data and the role groups. Idempotent, ORM-agnostic (goes through `runtime.models`). | + +The graph declaration lives in `ProjectGraphApp.setup()`: + +```ts +accessGraph: { + project: { + root: "Project", + membership: {through: "ProjectMember", via: "user", group: "group"}, + include: { + Task: {parent: "project"}, // direct edge to the root + Message: {parent: "task"}, // transitive, through Task + }, + bypassToken: "project-admin", + }, +} +``` + +Two things about the wiring are worth knowing before you copy this app into a project of your own: + +* **The host installs the tables, the app registers the models.** An app can add a model to the + panel, but not a table to someone else's ORM connection — so `fixture/index.ts` calls + `installProjectGraphSequelizeModels()` before enabling the app, and puts + `projectGraphTypeOrmModels` into the DataSource entity list (TypeORM cannot add entities after + `initialize()`). +* **Two things the core does at boot, an app must do itself.** Model CRUD tokens are registered + from `config.models` during `init`, and `/model//add` + `/edit/:id` routes are bound in + the same pass — a model that joins later gets neither. The app therefore registers its own four + tokens per model (an *unregistered* token id is denied to everyone but administrators) and its + own add/edit routes wrapping the built-in controllers. List, view, remove, inline editing and + filters need nothing: those routes are generic. + +Seeding is called by the host (`projectGraphApp.seedDemoData()`), not on `app:enabled`, because +the membership rows reference the fixture's own users, who are created after the apps are up. + +## Things to try next + +The declaration is complete as it stands; the commented-out lines in `setup()` switch on the two +performance stages without changing anything else. Both address the same thing: as declared, one +message list costs three queries — memberships, then the tasks of those projects, then the +messages, with every task id of every project you belong to inlined into that last one. On four +demo tasks nobody notices; on an account with hundreds of projects that inlined list, not the +page size, is what the read costs. + +* `graphRootField: {Message: "projectId"}` — a denormalized root id collapses the read filter to + one step (the column would have to be added to the model and maintained by the app). +* `pushdown: true` — the whole walk compiles into one nested-subquery SQL instead of + materialized id lists, so the statement stays the same size no matter how many projects the + user belongs to. Watch it with `Adminizer.logger.level = 'debug'` and Sequelize logging on. If a + chain cannot be compiled the model quietly goes back to materialization — quietly in the result, + that is: the log carries one `[accessGraph] ... declares pushdown: true, but ...` warning per + model with the reason. + +To see the graph fail closed, break it on purpose: point an `include` edge at an alias that does +not exist, or make `Message.parent` reach a model outside the graph. The panel starts, logs the +structural problem, and denies access to the affected models rather than leaving them open. diff --git a/fixture/apps/project-graph/projectGraphSeed.ts b/fixture/apps/project-graph/projectGraphSeed.ts new file mode 100644 index 00000000..40fccbbc --- /dev/null +++ b/fixture/apps/project-graph/projectGraphSeed.ts @@ -0,0 +1,158 @@ +/** + * Demo data of the access-graph app: two projects, their members, tasks and messages. + * + * Everything here is idempotent and ORM-agnostic — it goes through the app's own model + * access (`runtime.models`), so the same code seeds the Sequelize and the TypeORM fixture. + */ +import type {AppRuntime, InternalModelRepository} from "../../../dist"; +import {projectGraphModelNames} from "./ProjectGraphModels"; + +type Models = AppRuntime["models"]; +// Named explicitly rather than read off `Models["get"]`: extracting the return type of a +// generic method instantiates it with `unknown` instead of its `any` default, and a `where` +// over an unknown model accepts no field at all. +type Repo = InternalModelRepository; +type Row = Record; + +/** Roles are plain Group records: their tokens decide what a membership counts for. */ +export const projectGraphRoles = { + editor: "Project editors", + viewer: "Project viewers", +} as const; + +const MODEL_TOKENS = (modelName: string, verbs: string[]) => + verbs.map((verb) => `${verb}-${modelName}-model`.toLowerCase()); + +/** + * Full rights inside a project: everything on tasks and messages, plus renaming the + * project itself. Creating projects stays with administrators — a project nobody is a + * member of would be invisible to its own author. + */ +const EDITOR_TOKENS = [ + ...MODEL_TOKENS(projectGraphModelNames.project, ["read", "update"]), + ...MODEL_TOKENS(projectGraphModelNames.task, ["create", "read", "update", "delete"]), + ...MODEL_TOKENS(projectGraphModelNames.message, ["create", "read", "update", "delete"]), +]; + +/** + * The panel refuses the login itself for a user whose groups lack this token, so without + * it the demo users could not be tried out by hand at all. + */ +const PANEL_LOGIN_TOKEN = "access-to-adminpanel"; + +/** Read-only inside a project. */ +const VIEWER_TOKENS = [ + ...MODEL_TOKENS(projectGraphModelNames.project, ["read"]), + ...MODEL_TOKENS(projectGraphModelNames.task, ["read"]), + ...MODEL_TOKENS(projectGraphModelNames.message, ["read"]), +]; + +export interface ProjectGraphSeedOptions { + /** + * Global group of the demo users. Global tokens are the upper bound of the graph — + * without them a member cannot enter the section at all — so this group gets the + * editor token set and the per-project role narrows it back down. + */ + baseGroup: string; +} + +export async function seedProjectGraph(models: Models, options: ProjectGraphSeedOptions): Promise { + const groups = models.get("Group"); + const users = models.get("User"); + const projects = models.get(projectGraphModelNames.project); + const members = models.get(projectGraphModelNames.member); + const tasks = models.get(projectGraphModelNames.task); + const messages = models.get(projectGraphModelNames.message); + + const editorRole = await ensureGroup(groups, projectGraphRoles.editor, "Full access inside the projects they are a member of", EDITOR_TOKENS); + const viewerRole = await ensureGroup(groups, projectGraphRoles.viewer, "Read-only inside the projects they are a member of", VIEWER_TOKENS); + await grantTokens(groups, options.baseGroup, [PANEL_LOGIN_TOKEN, ...EDITOR_TOKENS]); + + if (await projects.count()) { + return; // already seeded + } + + const apollo = await projects.create({ + name: "Apollo", + description: "Landing page and delivery for the Apollo release", + }) as Row; + const borealis = await projects.create({ + name: "Borealis", + description: "Internal API of the Borealis platform", + }) as Row; + + // user1 works on Apollo, user3 watches Borealis, user2 sits in both — read-only in + // Apollo, editor in Borealis. Which is the point of the per-project role. + const memberships: [string, Row, Row | undefined][] = [ + ["user1", apollo, editorRole], + ["user2", apollo, viewerRole], + ["user2", borealis, editorRole], + ["user3", borealis, viewerRole], + ]; + + const userIds = new Map(); + for (const [login, project, role] of memberships) { + const user = await findUser(users, login, userIds); + if (!user) { + continue; + } + await members.create({project: project.id, user, group: role?.id ?? null}); + } + + const board: [Row, string, string, string[]][] = [ + [apollo, "Design the landing page", "in_progress", [ + "The hero section is ready for review.", + "Let's keep the pricing block above the fold.", + ]], + [apollo, "Set up CI", "open", [ + "Builds are green on the feature branches.", + ]], + [borealis, "Draft the API spec", "open", [ + "Pagination is still open — cursor or offset?", + "Cursor. Offsets break on concurrent writes.", + ]], + [borealis, "Pick a logo", "done", [ + "Third variant it is.", + ]], + ]; + + for (const [project, title, status, texts] of board) { + const task = await tasks.create({title, status, project: project.id}) as Row; + const author = await findUser(users, project === apollo ? "user1" : "user2", userIds); + for (const text of texts) { + await messages.create({text, task: task.id, author: author ?? null}); + } + } +} + +async function ensureGroup(groups: Repo, name: string, description: string, tokens: string[]): Promise { + const existing = await groups.findOne({where: {name}}) as Row | null; + if (existing) { + return existing; + } + return await groups.create({name, description, tokens}) as Row; +} + +/** Adds the demo tokens to an existing group without dropping what it already carries. */ +async function grantTokens(groups: Repo, name: string, tokens: string[]): Promise { + const group = await groups.findOne({where: {name}}) as Row | null; + if (!group) { + return; + } + + const current: unknown[] = Array.isArray(group.tokens) ? group.tokens : []; + const missing = tokens.filter((token) => !current.includes(token)); + if (!missing.length) { + return; + } + + await groups.updateOne({where: {id: group.id}}, {tokens: [...current, ...missing]} as Row); +} + +async function findUser(users: Repo, login: string, cache: Map): Promise { + if (!cache.has(login)) { + const user = await users.findOne({where: {login}}) as Row | null; + cache.set(login, user?.id); + } + return cache.get(login); +} diff --git a/fixture/apps/quill-editor/ReactQuill.ts b/fixture/apps/quill-editor/ReactQuill.ts new file mode 100644 index 00000000..fce22af6 --- /dev/null +++ b/fixture/apps/quill-editor/ReactQuill.ts @@ -0,0 +1,47 @@ +import path from "path"; +import {AbstractAdminizerApp} from "../../../dist"; +import type {AppSetupContext, Config} from "../../../dist"; + +interface ReactQuillAppConfig { + config: Config; + componentFile: string; + componentDevUrl: string; + stylesheetFile: string; + stylesheetDevUrl: string; +} + +export class ReactQuillApp extends AbstractAdminizerApp { + readonly name = "react-quill"; + readonly version = "1.0.0"; + declare readonly config: ReactQuillAppConfig; + + constructor(config: Partial = {}) { + super(); + this.config = { + config: {}, + componentFile: path.resolve(import.meta.dirname, "react-quill-editor.es.js"), + componentDevUrl: "/fixture/apps/quill-editor/react-quill-editor.tsx", + stylesheetFile: path.resolve(import.meta.dirname, "react-quill-editor.css"), + stylesheetDevUrl: "/fixture/apps/quill-editor/react-quill-editor.css", + ...config, + }; + } + + setup(ctx: AppSetupContext): void { + ctx.control({ + type: "wysiwyg", + name: "react-quill", + config: this.config.config, + component: { + id: "editor", + filePath: this.config.componentFile, + devUrl: this.config.componentDevUrl, + }, + stylesheet: { + id: "editor-css", + filePath: this.config.stylesheetFile, + devUrl: this.config.stylesheetDevUrl, + }, + }); + } +} diff --git a/fixture/apps/quill-editor/react-quill-editor.css b/fixture/apps/quill-editor/react-quill-editor.css new file mode 100644 index 00000000..77289f7a --- /dev/null +++ b/fixture/apps/quill-editor/react-quill-editor.css @@ -0,0 +1 @@ +.ql-container{box-sizing:border-box;font-family:Helvetica,Arial,sans-serif;font-size:13px;height:100%;margin:0;position:relative}.ql-container.ql-disabled .ql-tooltip{visibility:hidden}.ql-container:not(.ql-disabled) li[data-list=checked]>.ql-ui,.ql-container:not(.ql-disabled) li[data-list=unchecked]>.ql-ui{cursor:pointer}.ql-clipboard{left:-100000px;height:1px;overflow-y:hidden;position:absolute;top:50%}.ql-clipboard p{margin:0;padding:0}.ql-editor{box-sizing:border-box;counter-reset:list-0 list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9;line-height:1.42;height:100%;outline:none;overflow-y:auto;padding:12px 15px;tab-size:4;-moz-tab-size:4;text-align:left;white-space:pre-wrap;word-wrap:break-word}.ql-editor>*{cursor:text}.ql-editor p,.ql-editor ol,.ql-editor pre,.ql-editor blockquote,.ql-editor h1,.ql-editor h2,.ql-editor h3,.ql-editor h4,.ql-editor h5,.ql-editor h6{margin:0;padding:0}@supports (counter-set:none){.ql-editor p,.ql-editor h1,.ql-editor h2,.ql-editor h3,.ql-editor h4,.ql-editor h5,.ql-editor h6{counter-set:list-0 list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9}}@supports not (counter-set:none){.ql-editor p,.ql-editor h1,.ql-editor h2,.ql-editor h3,.ql-editor h4,.ql-editor h5,.ql-editor h6{counter-reset:list-0 list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9}}.ql-editor table{border-collapse:collapse}.ql-editor td{border:1px solid #000;padding:2px 5px}.ql-editor ol{padding-left:1.5em}.ql-editor li{list-style-type:none;padding-left:1.5em;position:relative}.ql-editor li>.ql-ui:before{display:inline-block;margin-left:-1.5em;margin-right:.3em;text-align:right;white-space:nowrap;width:1.2em}.ql-editor li[data-list=checked]>.ql-ui,.ql-editor li[data-list=unchecked]>.ql-ui{color:#777}.ql-editor li[data-list=bullet]>.ql-ui:before{content:"•"}.ql-editor li[data-list=checked]>.ql-ui:before{content:"☑"}.ql-editor li[data-list=unchecked]>.ql-ui:before{content:"☐"}@supports (counter-set:none){.ql-editor li[data-list]{counter-set:list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9}}@supports not (counter-set:none){.ql-editor li[data-list]{counter-reset:list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9}}.ql-editor li[data-list=ordered]{counter-increment:list-0}.ql-editor li[data-list=ordered]>.ql-ui:before{content:counter(list-0,decimal) ". "}.ql-editor li[data-list=ordered].ql-indent-1{counter-increment:list-1}.ql-editor li[data-list=ordered].ql-indent-1>.ql-ui:before{content:counter(list-1,lower-alpha) ". "}@supports (counter-set:none){.ql-editor li[data-list].ql-indent-1{counter-set:list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9}}@supports not (counter-set:none){.ql-editor li[data-list].ql-indent-1{counter-reset:list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9}}.ql-editor li[data-list=ordered].ql-indent-2{counter-increment:list-2}.ql-editor li[data-list=ordered].ql-indent-2>.ql-ui:before{content:counter(list-2,lower-roman) ". "}@supports (counter-set:none){.ql-editor li[data-list].ql-indent-2{counter-set:list-3 list-4 list-5 list-6 list-7 list-8 list-9}}@supports not (counter-set:none){.ql-editor li[data-list].ql-indent-2{counter-reset:list-3 list-4 list-5 list-6 list-7 list-8 list-9}}.ql-editor li[data-list=ordered].ql-indent-3{counter-increment:list-3}.ql-editor li[data-list=ordered].ql-indent-3>.ql-ui:before{content:counter(list-3,decimal) ". "}@supports (counter-set:none){.ql-editor li[data-list].ql-indent-3{counter-set:list-4 list-5 list-6 list-7 list-8 list-9}}@supports not (counter-set:none){.ql-editor li[data-list].ql-indent-3{counter-reset:list-4 list-5 list-6 list-7 list-8 list-9}}.ql-editor li[data-list=ordered].ql-indent-4{counter-increment:list-4}.ql-editor li[data-list=ordered].ql-indent-4>.ql-ui:before{content:counter(list-4,lower-alpha) ". "}@supports (counter-set:none){.ql-editor li[data-list].ql-indent-4{counter-set:list-5 list-6 list-7 list-8 list-9}}@supports not (counter-set:none){.ql-editor li[data-list].ql-indent-4{counter-reset:list-5 list-6 list-7 list-8 list-9}}.ql-editor li[data-list=ordered].ql-indent-5{counter-increment:list-5}.ql-editor li[data-list=ordered].ql-indent-5>.ql-ui:before{content:counter(list-5,lower-roman) ". "}@supports (counter-set:none){.ql-editor li[data-list].ql-indent-5{counter-set:list-6 list-7 list-8 list-9}}@supports not (counter-set:none){.ql-editor li[data-list].ql-indent-5{counter-reset:list-6 list-7 list-8 list-9}}.ql-editor li[data-list=ordered].ql-indent-6{counter-increment:list-6}.ql-editor li[data-list=ordered].ql-indent-6>.ql-ui:before{content:counter(list-6,decimal) ". "}@supports (counter-set:none){.ql-editor li[data-list].ql-indent-6{counter-set:list-7 list-8 list-9}}@supports not (counter-set:none){.ql-editor li[data-list].ql-indent-6{counter-reset:list-7 list-8 list-9}}.ql-editor li[data-list=ordered].ql-indent-7{counter-increment:list-7}.ql-editor li[data-list=ordered].ql-indent-7>.ql-ui:before{content:counter(list-7,lower-alpha) ". "}@supports (counter-set:none){.ql-editor li[data-list].ql-indent-7{counter-set:list-8 list-9}}@supports not (counter-set:none){.ql-editor li[data-list].ql-indent-7{counter-reset:list-8 list-9}}.ql-editor li[data-list=ordered].ql-indent-8{counter-increment:list-8}.ql-editor li[data-list=ordered].ql-indent-8>.ql-ui:before{content:counter(list-8,lower-roman) ". "}@supports (counter-set:none){.ql-editor li[data-list].ql-indent-8{counter-set:list-9}}@supports not (counter-set:none){.ql-editor li[data-list].ql-indent-8{counter-reset:list-9}}.ql-editor li[data-list=ordered].ql-indent-9{counter-increment:list-9}.ql-editor li[data-list=ordered].ql-indent-9>.ql-ui:before{content:counter(list-9,decimal) ". "}.ql-editor .ql-indent-1:not(.ql-direction-rtl){padding-left:3em}.ql-editor li.ql-indent-1:not(.ql-direction-rtl){padding-left:4.5em}.ql-editor .ql-indent-1.ql-direction-rtl.ql-align-right{padding-right:3em}.ql-editor li.ql-indent-1.ql-direction-rtl.ql-align-right{padding-right:4.5em}.ql-editor .ql-indent-2:not(.ql-direction-rtl){padding-left:6em}.ql-editor li.ql-indent-2:not(.ql-direction-rtl){padding-left:7.5em}.ql-editor .ql-indent-2.ql-direction-rtl.ql-align-right{padding-right:6em}.ql-editor li.ql-indent-2.ql-direction-rtl.ql-align-right{padding-right:7.5em}.ql-editor .ql-indent-3:not(.ql-direction-rtl){padding-left:9em}.ql-editor li.ql-indent-3:not(.ql-direction-rtl){padding-left:10.5em}.ql-editor .ql-indent-3.ql-direction-rtl.ql-align-right{padding-right:9em}.ql-editor li.ql-indent-3.ql-direction-rtl.ql-align-right{padding-right:10.5em}.ql-editor .ql-indent-4:not(.ql-direction-rtl){padding-left:12em}.ql-editor li.ql-indent-4:not(.ql-direction-rtl){padding-left:13.5em}.ql-editor .ql-indent-4.ql-direction-rtl.ql-align-right{padding-right:12em}.ql-editor li.ql-indent-4.ql-direction-rtl.ql-align-right{padding-right:13.5em}.ql-editor .ql-indent-5:not(.ql-direction-rtl){padding-left:15em}.ql-editor li.ql-indent-5:not(.ql-direction-rtl){padding-left:16.5em}.ql-editor .ql-indent-5.ql-direction-rtl.ql-align-right{padding-right:15em}.ql-editor li.ql-indent-5.ql-direction-rtl.ql-align-right{padding-right:16.5em}.ql-editor .ql-indent-6:not(.ql-direction-rtl){padding-left:18em}.ql-editor li.ql-indent-6:not(.ql-direction-rtl){padding-left:19.5em}.ql-editor .ql-indent-6.ql-direction-rtl.ql-align-right{padding-right:18em}.ql-editor li.ql-indent-6.ql-direction-rtl.ql-align-right{padding-right:19.5em}.ql-editor .ql-indent-7:not(.ql-direction-rtl){padding-left:21em}.ql-editor li.ql-indent-7:not(.ql-direction-rtl){padding-left:22.5em}.ql-editor .ql-indent-7.ql-direction-rtl.ql-align-right{padding-right:21em}.ql-editor li.ql-indent-7.ql-direction-rtl.ql-align-right{padding-right:22.5em}.ql-editor .ql-indent-8:not(.ql-direction-rtl){padding-left:24em}.ql-editor li.ql-indent-8:not(.ql-direction-rtl){padding-left:25.5em}.ql-editor .ql-indent-8.ql-direction-rtl.ql-align-right{padding-right:24em}.ql-editor li.ql-indent-8.ql-direction-rtl.ql-align-right{padding-right:25.5em}.ql-editor .ql-indent-9:not(.ql-direction-rtl){padding-left:27em}.ql-editor li.ql-indent-9:not(.ql-direction-rtl){padding-left:28.5em}.ql-editor .ql-indent-9.ql-direction-rtl.ql-align-right{padding-right:27em}.ql-editor li.ql-indent-9.ql-direction-rtl.ql-align-right{padding-right:28.5em}.ql-editor li.ql-direction-rtl{padding-right:1.5em}.ql-editor li.ql-direction-rtl>.ql-ui:before{margin-left:.3em;margin-right:-1.5em;text-align:left}.ql-editor table{table-layout:fixed;width:100%}.ql-editor table td{outline:none}.ql-editor .ql-code-block-container{font-family:monospace}.ql-editor .ql-video{display:block;max-width:100%}.ql-editor .ql-video.ql-align-center{margin:0 auto}.ql-editor .ql-video.ql-align-right{margin:0 0 0 auto}.ql-editor .ql-bg-black{background-color:#000}.ql-editor .ql-bg-red{background-color:#e60000}.ql-editor .ql-bg-orange{background-color:#f90}.ql-editor .ql-bg-yellow{background-color:#ff0}.ql-editor .ql-bg-green{background-color:#008a00}.ql-editor .ql-bg-blue{background-color:#06c}.ql-editor .ql-bg-purple{background-color:#93f}.ql-editor .ql-color-white{color:#fff}.ql-editor .ql-color-red{color:#e60000}.ql-editor .ql-color-orange{color:#f90}.ql-editor .ql-color-yellow{color:#ff0}.ql-editor .ql-color-green{color:#008a00}.ql-editor .ql-color-blue{color:#06c}.ql-editor .ql-color-purple{color:#93f}.ql-editor .ql-font-serif{font-family:Georgia,Times New Roman,serif}.ql-editor .ql-font-monospace{font-family:Monaco,Courier New,monospace}.ql-editor .ql-size-small{font-size:.75em}.ql-editor .ql-size-large{font-size:1.5em}.ql-editor .ql-size-huge{font-size:2.5em}.ql-editor .ql-direction-rtl{direction:rtl;text-align:inherit}.ql-editor .ql-align-center{text-align:center}.ql-editor .ql-align-justify{text-align:justify}.ql-editor .ql-align-right{text-align:right}.ql-editor .ql-ui{position:absolute}.ql-editor.ql-blank:before{color:#0009;content:attr(data-placeholder);font-style:italic;left:15px;pointer-events:none;position:absolute;right:15px}.ql-snow.ql-toolbar:after,.ql-snow .ql-toolbar:after{clear:both;content:"";display:table}.ql-snow.ql-toolbar button,.ql-snow .ql-toolbar button{background:none;border:none;cursor:pointer;display:inline-block;float:left;height:24px;padding:3px 5px;width:28px}.ql-snow.ql-toolbar button svg,.ql-snow .ql-toolbar button svg{float:left;height:100%}.ql-snow.ql-toolbar button:active:hover,.ql-snow .ql-toolbar button:active:hover{outline:none}.ql-snow.ql-toolbar input.ql-image[type=file],.ql-snow .ql-toolbar input.ql-image[type=file]{display:none}.ql-snow.ql-toolbar button:hover,.ql-snow .ql-toolbar button:hover,.ql-snow.ql-toolbar button:focus,.ql-snow .ql-toolbar button:focus,.ql-snow.ql-toolbar button.ql-active,.ql-snow .ql-toolbar button.ql-active,.ql-snow.ql-toolbar .ql-picker-label:hover,.ql-snow .ql-toolbar .ql-picker-label:hover,.ql-snow.ql-toolbar .ql-picker-label.ql-active,.ql-snow .ql-toolbar .ql-picker-label.ql-active,.ql-snow.ql-toolbar .ql-picker-item:hover,.ql-snow .ql-toolbar .ql-picker-item:hover,.ql-snow.ql-toolbar .ql-picker-item.ql-selected,.ql-snow .ql-toolbar .ql-picker-item.ql-selected{color:#06c}.ql-snow.ql-toolbar button:hover .ql-fill,.ql-snow .ql-toolbar button:hover .ql-fill,.ql-snow.ql-toolbar button:focus .ql-fill,.ql-snow .ql-toolbar button:focus .ql-fill,.ql-snow.ql-toolbar button.ql-active .ql-fill,.ql-snow .ql-toolbar button.ql-active .ql-fill,.ql-snow.ql-toolbar .ql-picker-label:hover .ql-fill,.ql-snow .ql-toolbar .ql-picker-label:hover .ql-fill,.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-fill,.ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-fill,.ql-snow.ql-toolbar .ql-picker-item:hover .ql-fill,.ql-snow .ql-toolbar .ql-picker-item:hover .ql-fill,.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-fill,.ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-fill,.ql-snow.ql-toolbar button:hover .ql-stroke.ql-fill,.ql-snow .ql-toolbar button:hover .ql-stroke.ql-fill,.ql-snow.ql-toolbar button:focus .ql-stroke.ql-fill,.ql-snow .ql-toolbar button:focus .ql-stroke.ql-fill,.ql-snow.ql-toolbar button.ql-active .ql-stroke.ql-fill,.ql-snow .ql-toolbar button.ql-active .ql-stroke.ql-fill,.ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke.ql-fill,.ql-snow .ql-toolbar .ql-picker-label:hover .ql-stroke.ql-fill,.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke.ql-fill,.ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-stroke.ql-fill,.ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke.ql-fill,.ql-snow .ql-toolbar .ql-picker-item:hover .ql-stroke.ql-fill,.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke.ql-fill,.ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-stroke.ql-fill{fill:#06c}.ql-snow.ql-toolbar button:hover .ql-stroke,.ql-snow .ql-toolbar button:hover .ql-stroke,.ql-snow.ql-toolbar button:focus .ql-stroke,.ql-snow .ql-toolbar button:focus .ql-stroke,.ql-snow.ql-toolbar button.ql-active .ql-stroke,.ql-snow .ql-toolbar button.ql-active .ql-stroke,.ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke,.ql-snow .ql-toolbar .ql-picker-label:hover .ql-stroke,.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke,.ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-stroke,.ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke,.ql-snow .ql-toolbar .ql-picker-item:hover .ql-stroke,.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke,.ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-stroke,.ql-snow.ql-toolbar button:hover .ql-stroke-miter,.ql-snow .ql-toolbar button:hover .ql-stroke-miter,.ql-snow.ql-toolbar button:focus .ql-stroke-miter,.ql-snow .ql-toolbar button:focus .ql-stroke-miter,.ql-snow.ql-toolbar button.ql-active .ql-stroke-miter,.ql-snow .ql-toolbar button.ql-active .ql-stroke-miter,.ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke-miter,.ql-snow .ql-toolbar .ql-picker-label:hover .ql-stroke-miter,.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke-miter,.ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-stroke-miter,.ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke-miter,.ql-snow .ql-toolbar .ql-picker-item:hover .ql-stroke-miter,.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke-miter,.ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-stroke-miter{stroke:#06c}@media(pointer:coarse){.ql-snow.ql-toolbar button:hover:not(.ql-active),.ql-snow .ql-toolbar button:hover:not(.ql-active){color:#444}.ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-fill,.ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-fill,.ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-stroke.ql-fill,.ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-stroke.ql-fill{fill:#444}.ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-stroke,.ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-stroke,.ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-stroke-miter,.ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-stroke-miter{stroke:#444}}.ql-snow,.ql-snow *{box-sizing:border-box}.ql-snow .ql-hidden{display:none}.ql-snow .ql-out-bottom,.ql-snow .ql-out-top{visibility:hidden}.ql-snow .ql-tooltip{position:absolute;transform:translateY(10px)}.ql-snow .ql-tooltip a{cursor:pointer;text-decoration:none}.ql-snow .ql-tooltip.ql-flip{transform:translateY(-10px)}.ql-snow .ql-formats{display:inline-block;vertical-align:middle}.ql-snow .ql-formats:after{clear:both;content:"";display:table}.ql-snow .ql-stroke{fill:none;stroke:#444;stroke-linecap:round;stroke-linejoin:round;stroke-width:2}.ql-snow .ql-stroke-miter{fill:none;stroke:#444;stroke-miterlimit:10;stroke-width:2}.ql-snow .ql-fill,.ql-snow .ql-stroke.ql-fill{fill:#444}.ql-snow .ql-empty{fill:none}.ql-snow .ql-even{fill-rule:evenodd}.ql-snow .ql-thin,.ql-snow .ql-stroke.ql-thin{stroke-width:1}.ql-snow .ql-transparent{opacity:.4}.ql-snow .ql-direction svg:last-child{display:none}.ql-snow .ql-direction.ql-active svg:last-child{display:inline}.ql-snow .ql-direction.ql-active svg:first-child{display:none}.ql-snow .ql-editor h1{font-size:2em}.ql-snow .ql-editor h2{font-size:1.5em}.ql-snow .ql-editor h3{font-size:1.17em}.ql-snow .ql-editor h4{font-size:1em}.ql-snow .ql-editor h5{font-size:.83em}.ql-snow .ql-editor h6{font-size:.67em}.ql-snow .ql-editor a{text-decoration:underline}.ql-snow .ql-editor blockquote{border-left:4px solid #ccc;margin-bottom:5px;margin-top:5px;padding-left:16px}.ql-snow .ql-editor code,.ql-snow .ql-editor .ql-code-block-container{background-color:#f0f0f0;border-radius:3px}.ql-snow .ql-editor .ql-code-block-container{margin-bottom:5px;margin-top:5px;padding:5px 10px}.ql-snow .ql-editor code{font-size:85%;padding:2px 4px}.ql-snow .ql-editor .ql-code-block-container{background-color:#23241f;color:#f8f8f2;overflow:visible}.ql-snow .ql-editor img{max-width:100%}.ql-snow .ql-picker{color:#444;display:inline-block;float:left;font-size:14px;font-weight:500;height:24px;position:relative;vertical-align:middle}.ql-snow .ql-picker-label{cursor:pointer;display:inline-block;height:100%;padding-left:8px;padding-right:2px;position:relative;width:100%}.ql-snow .ql-picker-label:before{display:inline-block;line-height:22px}.ql-snow .ql-picker-options{background-color:#fff;display:none;min-width:100%;padding:4px 8px;position:absolute;white-space:nowrap}.ql-snow .ql-picker-options .ql-picker-item{cursor:pointer;display:block;padding-bottom:5px;padding-top:5px}.ql-snow .ql-picker.ql-expanded .ql-picker-label{color:#ccc;z-index:2}.ql-snow .ql-picker.ql-expanded .ql-picker-label .ql-fill{fill:#ccc}.ql-snow .ql-picker.ql-expanded .ql-picker-label .ql-stroke{stroke:#ccc}.ql-snow .ql-picker.ql-expanded .ql-picker-options{display:block;margin-top:-1px;top:100%;z-index:1}.ql-snow .ql-color-picker,.ql-snow .ql-icon-picker{width:28px}.ql-snow .ql-color-picker .ql-picker-label,.ql-snow .ql-icon-picker .ql-picker-label{padding:2px 4px}.ql-snow .ql-color-picker .ql-picker-label svg,.ql-snow .ql-icon-picker .ql-picker-label svg{right:4px}.ql-snow .ql-icon-picker .ql-picker-options{padding:4px 0}.ql-snow .ql-icon-picker .ql-picker-item{height:24px;width:24px;padding:2px 4px}.ql-snow .ql-color-picker .ql-picker-options{padding:3px 5px;width:152px}.ql-snow .ql-color-picker .ql-picker-item{border:1px solid transparent;float:left;height:16px;margin:2px;padding:0;width:16px}.ql-snow .ql-picker:not(.ql-color-picker):not(.ql-icon-picker) svg{position:absolute;margin-top:-9px;right:0;top:50%;width:18px}.ql-snow .ql-picker.ql-header .ql-picker-label[data-label]:not([data-label=""]):before,.ql-snow .ql-picker.ql-font .ql-picker-label[data-label]:not([data-label=""]):before,.ql-snow .ql-picker.ql-size .ql-picker-label[data-label]:not([data-label=""]):before,.ql-snow .ql-picker.ql-header .ql-picker-item[data-label]:not([data-label=""]):before,.ql-snow .ql-picker.ql-font .ql-picker-item[data-label]:not([data-label=""]):before,.ql-snow .ql-picker.ql-size .ql-picker-item[data-label]:not([data-label=""]):before{content:attr(data-label)}.ql-snow .ql-picker.ql-header{width:98px}.ql-snow .ql-picker.ql-header .ql-picker-label:before,.ql-snow .ql-picker.ql-header .ql-picker-item:before{content:"Normal"}.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="1"]:before,.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="1"]:before{content:"Heading 1"}.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="2"]:before,.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="2"]:before{content:"Heading 2"}.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="3"]:before,.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="3"]:before{content:"Heading 3"}.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="4"]:before,.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="4"]:before{content:"Heading 4"}.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="5"]:before,.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="5"]:before{content:"Heading 5"}.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="6"]:before,.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="6"]:before{content:"Heading 6"}.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="1"]:before{font-size:2em}.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="2"]:before{font-size:1.5em}.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="3"]:before{font-size:1.17em}.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="4"]:before{font-size:1em}.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="5"]:before{font-size:.83em}.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="6"]:before{font-size:.67em}.ql-snow .ql-picker.ql-font{width:108px}.ql-snow .ql-picker.ql-font .ql-picker-label:before,.ql-snow .ql-picker.ql-font .ql-picker-item:before{content:"Sans Serif"}.ql-snow .ql-picker.ql-font .ql-picker-label[data-value=serif]:before,.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=serif]:before{content:"Serif"}.ql-snow .ql-picker.ql-font .ql-picker-label[data-value=monospace]:before,.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=monospace]:before{content:"Monospace"}.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=serif]:before{font-family:Georgia,Times New Roman,serif}.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=monospace]:before{font-family:Monaco,Courier New,monospace}.ql-snow .ql-picker.ql-size{width:98px}.ql-snow .ql-picker.ql-size .ql-picker-label:before,.ql-snow .ql-picker.ql-size .ql-picker-item:before{content:"Normal"}.ql-snow .ql-picker.ql-size .ql-picker-label[data-value=small]:before,.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=small]:before{content:"Small"}.ql-snow .ql-picker.ql-size .ql-picker-label[data-value=large]:before,.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=large]:before{content:"Large"}.ql-snow .ql-picker.ql-size .ql-picker-label[data-value=huge]:before,.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=huge]:before{content:"Huge"}.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=small]:before{font-size:10px}.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=large]:before{font-size:18px}.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=huge]:before{font-size:32px}.ql-snow .ql-color-picker.ql-background .ql-picker-item{background-color:#fff}.ql-snow .ql-color-picker.ql-color .ql-picker-item{background-color:#000}.ql-code-block-container{position:relative}.ql-code-block-container .ql-ui{right:5px;top:5px}.ql-toolbar.ql-snow{border:1px solid #ccc;box-sizing:border-box;font-family:Helvetica Neue,Helvetica,Arial,sans-serif;padding:8px}.ql-toolbar.ql-snow .ql-formats{margin-right:15px}.ql-toolbar.ql-snow .ql-picker-label{border:1px solid transparent}.ql-toolbar.ql-snow .ql-picker-options{border:1px solid transparent;box-shadow:#0003 0 2px 8px}.ql-toolbar.ql-snow .ql-picker.ql-expanded .ql-picker-label,.ql-toolbar.ql-snow .ql-picker.ql-expanded .ql-picker-options{border-color:#ccc}.ql-toolbar.ql-snow .ql-color-picker .ql-picker-item.ql-selected,.ql-toolbar.ql-snow .ql-color-picker .ql-picker-item:hover{border-color:#000}.ql-toolbar.ql-snow+.ql-container.ql-snow{border-top:0}.ql-snow .ql-tooltip{background-color:#fff;border:1px solid #ccc;box-shadow:0 0 5px #ddd;color:#444;padding:5px 12px;white-space:nowrap}.ql-snow .ql-tooltip:before{content:"Visit URL:";line-height:26px;margin-right:8px}.ql-snow .ql-tooltip input[type=text]{display:none;border:1px solid #ccc;font-size:13px;height:26px;margin:0;padding:3px 5px;width:170px}.ql-snow .ql-tooltip a.ql-preview{display:inline-block;max-width:200px;overflow-x:hidden;text-overflow:ellipsis;vertical-align:top}.ql-snow .ql-tooltip a.ql-action:after{border-right:1px solid #ccc;content:"Edit";margin-left:16px;padding-right:8px}.ql-snow .ql-tooltip a.ql-remove:before{content:"Remove";margin-left:8px}.ql-snow .ql-tooltip a{line-height:26px}.ql-snow .ql-tooltip.ql-editing a.ql-preview,.ql-snow .ql-tooltip.ql-editing a.ql-remove{display:none}.ql-snow .ql-tooltip.ql-editing input[type=text]{display:inline-block}.ql-snow .ql-tooltip.ql-editing a.ql-action:after{border-right:0;content:"Save";padding-right:0}.ql-snow .ql-tooltip[data-mode=link]:before{content:"Enter link:"}.ql-snow .ql-tooltip[data-mode=formula]:before{content:"Enter formula:"}.ql-snow .ql-tooltip[data-mode=video]:before{content:"Enter video:"}.ql-snow a{color:#06c}.ql-container.ql-snow{border:1px solid #ccc} diff --git a/controls/wysiwyg/react-quill-editor.es.js b/fixture/apps/quill-editor/react-quill-editor.es.js similarity index 61% rename from controls/wysiwyg/react-quill-editor.es.js rename to fixture/apps/quill-editor/react-quill-editor.es.js index ac54074b..4411f6c2 100644 --- a/controls/wysiwyg/react-quill-editor.es.js +++ b/fixture/apps/quill-editor/react-quill-editor.es.js @@ -1,25 +1,13 @@ -var Ko = Object.defineProperty; -var Go = (r, t, e) => t in r ? Ko(r, t, { enumerable: !0, configurable: !0, writable: !0, value: e }) : r[t] = e; -var O = (r, t, e) => Go(r, typeof t != "symbol" ? t + "" : t, e); -var me = typeof globalThis < "u" ? globalThis : typeof window < "u" ? window : typeof global < "u" ? global : typeof self < "u" ? self : {}; -function Dl(r) { +var ue = typeof globalThis < "u" ? globalThis : typeof window < "u" ? window : typeof global < "u" ? global : typeof self < "u" ? self : {}; +function Nl(r) { return r && r.__esModule && Object.prototype.hasOwnProperty.call(r, "default") ? r.default : r; } -var _s = { exports: {} }, _n = {}; -/** - * @license React - * react-jsx-runtime.production.js - * - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ -var Ci; -function Vo() { - if (Ci) return _n; - Ci = 1; - var r = Symbol.for("react.transitional.element"), t = Symbol.for("react.fragment"); +var vs = { exports: {} }, An = {}; +var mi; +function So() { + if (mi) return An; + mi = 1; + var r = /* @__PURE__ */ Symbol.for("react.transitional.element"), t = /* @__PURE__ */ Symbol.for("react.fragment"); function e(n, s, i) { var o = null; if (i !== void 0 && (o = "" + i), s.key !== void 0 && (o = "" + s.key), "key" in s) { @@ -35,61 +23,61 @@ function Vo() { props: i }; } - return _n.Fragment = t, _n.jsx = e, _n.jsxs = e, _n; + return An.Fragment = t, An.jsx = e, An.jsxs = e, An; } -var Ii; -function Wo() { - return Ii || (Ii = 1, _s.exports = Vo()), _s.exports; +var bi; +function _o() { + return bi || (bi = 1, vs.exports = So()), vs.exports; } -var Zo = Wo(), jl = typeof global == "object" && global && global.Object === Object && global, Yo = typeof self == "object" && self && self.Object === Object && self, ee = jl || Yo || Function("return this")(), Ee = ee.Symbol, $l = Object.prototype, Xo = $l.hasOwnProperty, Qo = $l.toString, qn = Ee ? Ee.toStringTag : void 0; -function Jo(r) { - var t = Xo.call(r, qn), e = r[qn]; +var pr = _o(), Tl = typeof global == "object" && global && global.Object === Object && global, Co = typeof self == "object" && self && self.Object === Object && self, Zt = Tl || Co || Function("return this")(), ge = Zt.Symbol, xl = Object.prototype, qo = xl.hasOwnProperty, Oo = xl.toString, wn = ge ? ge.toStringTag : void 0; +function Ro(r) { + var t = qo.call(r, wn), e = r[wn]; try { - r[qn] = void 0; + r[wn] = void 0; var n = !0; } catch { } - var s = Qo.call(r); - return n && (t ? r[qn] = e : delete r[qn]), s; + var s = Oo.call(r); + return n && (t ? r[wn] = e : delete r[wn]), s; } -var ta = Object.prototype, ea = ta.toString; -function na(r) { - return ea.call(r); +var Io = Object.prototype, ko = Io.toString; +function Bo(r) { + return ko.call(r); } -var ra = "[object Null]", sa = "[object Undefined]", Ri = Ee ? Ee.toStringTag : void 0; -function hn(r) { - return r == null ? r === void 0 ? sa : ra : Ri && Ri in Object(r) ? Jo(r) : na(r); +var Mo = "[object Null]", Do = "[object Undefined]", yi = ge ? ge.toStringTag : void 0; +function nn(r) { + return r == null ? r === void 0 ? Do : Mo : yi && yi in Object(r) ? Ro(r) : Bo(r); } -function oe(r) { +function ne(r) { return r != null && typeof r == "object"; } -var Oe = Array.isArray; -function Ae(r) { +var xe = Array.isArray; +function pe(r) { var t = typeof r; return r != null && (t == "object" || t == "function"); } -function Pl(r) { +function Ll(r) { return r; } -var ia = "[object AsyncFunction]", la = "[object Function]", oa = "[object GeneratorFunction]", aa = "[object Proxy]"; -function fi(r) { - if (!Ae(r)) +var jo = "[object AsyncFunction]", Po = "[object Function]", $o = "[object GeneratorFunction]", Uo = "[object Proxy]"; +function Ys(r) { + if (!pe(r)) return !1; - var t = hn(r); - return t == la || t == oa || t == ia || t == aa; + var t = nn(r); + return t == Po || t == $o || t == jo || t == Uo; } -var qs = ee["__core-js_shared__"], ki = function() { - var r = /[^.]+$/.exec(qs && qs.keys && qs.keys.IE_PROTO || ""); +var Es = Zt["__core-js_shared__"], vi = (function() { + var r = /[^.]+$/.exec(Es && Es.keys && Es.keys.IE_PROTO || ""); return r ? "Symbol(src)_1." + r : ""; -}(); -function ca(r) { - return !!ki && ki in r; +})(); +function Fo(r) { + return !!vi && vi in r; } -var ua = Function.prototype, ha = ua.toString; -function Re(r) { +var Ho = Function.prototype, zo = Ho.toString; +function Ce(r) { if (r != null) { try { - return ha.call(r); + return zo.call(r); } catch { } try { @@ -99,36 +87,36 @@ function Re(r) { } return ""; } -var fa = /[\\^$.*+?()[\]{}|]/g, da = /^\[object .+?Constructor\]$/, ga = Function.prototype, pa = Object.prototype, ma = ga.toString, ba = pa.hasOwnProperty, ya = RegExp( - "^" + ma.call(ba).replace(fa, "\\$&").replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g, "$1.*?") + "$" +var Ko = /[\\^$.*+?()[\]{}|]/g, Go = /^\[object .+?Constructor\]$/, Vo = Function.prototype, Wo = Object.prototype, Zo = Vo.toString, Yo = Wo.hasOwnProperty, Xo = RegExp( + "^" + Zo.call(Yo).replace(Ko, "\\$&").replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g, "$1.*?") + "$" ); -function va(r) { - if (!Ae(r) || ca(r)) +function Qo(r) { + if (!pe(r) || Fo(r)) return !1; - var t = fi(r) ? ya : da; - return t.test(Re(r)); + var t = Ys(r) ? Xo : Go; + return t.test(Ce(r)); } -function Ea(r, t) { - return r == null ? void 0 : r[t]; +function Jo(r, t) { + return r?.[t]; } -function ke(r, t) { - var e = Ea(r, t); - return va(e) ? e : void 0; +function qe(r, t) { + var e = Jo(r, t); + return Qo(e) ? e : void 0; } -var Hs = ke(ee, "WeakMap"), Bi = Object.create, Aa = /* @__PURE__ */ function() { +var ks = qe(Zt, "WeakMap"), Ei = Object.create, ta = /* @__PURE__ */ (function() { function r() { } return function(t) { - if (!Ae(t)) + if (!pe(t)) return {}; - if (Bi) - return Bi(t); + if (Ei) + return Ei(t); r.prototype = t; var e = new r(); return r.prototype = void 0, e; }; -}(); -function wa(r, t, e) { +})(); +function ea(r, t, e) { switch (e.length) { case 0: return r.call(t); @@ -141,568 +129,568 @@ function wa(r, t, e) { } return r.apply(t, e); } -function Na(r, t) { +function na(r, t) { var e = -1, n = r.length; for (t || (t = Array(n)); ++e < n; ) t[e] = r[e]; return t; } -var Ta = 800, xa = 16, La = Date.now; -function Sa(r) { +var ra = 800, sa = 16, ia = Date.now; +function la(r) { var t = 0, e = 0; return function() { - var n = La(), s = xa - (n - e); + var n = ia(), s = sa - (n - e); if (e = n, s > 0) { - if (++t >= Ta) + if (++t >= ra) return arguments[0]; } else t = 0; return r.apply(void 0, arguments); }; } -function _a(r) { +function oa(r) { return function() { return r; }; } -var _r = function() { +var br = (function() { try { - var r = ke(Object, "defineProperty"); + var r = qe(Object, "defineProperty"); return r({}, "", {}), r; } catch { } -}(), qa = _r ? function(r, t) { - return _r(r, "toString", { +})(), aa = br ? function(r, t) { + return br(r, "toString", { configurable: !0, enumerable: !1, - value: _a(t), + value: oa(t), writable: !0 }); -} : Pl, Oa = Sa(qa); -function Ca(r, t) { +} : Ll, ca = la(aa); +function ua(r, t) { for (var e = -1, n = r == null ? 0 : r.length; ++e < n && t(r[e], e, r) !== !1; ) ; return r; } -var Ia = 9007199254740991, Ra = /^(?:0|[1-9]\d*)$/; -function Ul(r, t) { +var ha = 9007199254740991, fa = /^(?:0|[1-9]\d*)$/; +function Sl(r, t) { var e = typeof r; - return t = t ?? Ia, !!t && (e == "number" || e != "symbol" && Ra.test(r)) && r > -1 && r % 1 == 0 && r < t; + return t = t ?? ha, !!t && (e == "number" || e != "symbol" && fa.test(r)) && r > -1 && r % 1 == 0 && r < t; } -function di(r, t, e) { - t == "__proto__" && _r ? _r(r, t, { +function Xs(r, t, e) { + t == "__proto__" && br ? br(r, t, { configurable: !0, enumerable: !0, value: e, writable: !0 }) : r[t] = e; } -function Kn(r, t) { +function Bn(r, t) { return r === t || r !== r && t !== t; } -var ka = Object.prototype, Ba = ka.hasOwnProperty; -function Fl(r, t, e) { +var da = Object.prototype, ga = da.hasOwnProperty; +function _l(r, t, e) { var n = r[t]; - (!(Ba.call(r, t) && Kn(n, e)) || e === void 0 && !(t in r)) && di(r, t, e); + (!(ga.call(r, t) && Bn(n, e)) || e === void 0 && !(t in r)) && Xs(r, t, e); } -function Ma(r, t, e, n) { +function pa(r, t, e, n) { var s = !e; e || (e = {}); for (var i = -1, o = t.length; ++i < o; ) { var a = t[i], u = void 0; - u === void 0 && (u = r[a]), s ? di(e, a, u) : Fl(e, a, u); + u === void 0 && (u = r[a]), s ? Xs(e, a, u) : _l(e, a, u); } return e; } -var Mi = Math.max; -function Da(r, t, e) { - return t = Mi(t === void 0 ? r.length - 1 : t, 0), function() { - for (var n = arguments, s = -1, i = Mi(n.length - t, 0), o = Array(i); ++s < i; ) +var Ai = Math.max; +function ma(r, t, e) { + return t = Ai(t === void 0 ? r.length - 1 : t, 0), function() { + for (var n = arguments, s = -1, i = Ai(n.length - t, 0), o = Array(i); ++s < i; ) o[s] = n[t + s]; s = -1; for (var a = Array(t + 1); ++s < t; ) a[s] = n[s]; - return a[t] = e(o), wa(r, this, a); + return a[t] = e(o), ea(r, this, a); }; } -function ja(r, t) { - return Oa(Da(r, t, Pl), r + ""); +function ba(r, t) { + return ca(ma(r, t, Ll), r + ""); } -var $a = 9007199254740991; -function Hl(r) { - return typeof r == "number" && r > -1 && r % 1 == 0 && r <= $a; +var ya = 9007199254740991; +function Cl(r) { + return typeof r == "number" && r > -1 && r % 1 == 0 && r <= ya; } -function Ir(r) { - return r != null && Hl(r.length) && !fi(r); +function Nr(r) { + return r != null && Cl(r.length) && !Ys(r); } -function Pa(r, t, e) { - if (!Ae(e)) +function va(r, t, e) { + if (!pe(e)) return !1; var n = typeof t; - return (n == "number" ? Ir(e) && Ul(t, e.length) : n == "string" && t in e) ? Kn(e[t], r) : !1; + return (n == "number" ? Nr(e) && Sl(t, e.length) : n == "string" && t in e) ? Bn(e[t], r) : !1; } -function Ua(r) { - return ja(function(t, e) { +function Ea(r) { + return ba(function(t, e) { var n = -1, s = e.length, i = s > 1 ? e[s - 1] : void 0, o = s > 2 ? e[2] : void 0; - for (i = r.length > 3 && typeof i == "function" ? (s--, i) : void 0, o && Pa(e[0], e[1], o) && (i = s < 3 ? void 0 : i, s = 1), t = Object(t); ++n < s; ) { + for (i = r.length > 3 && typeof i == "function" ? (s--, i) : void 0, o && va(e[0], e[1], o) && (i = s < 3 ? void 0 : i, s = 1), t = Object(t); ++n < s; ) { var a = e[n]; a && r(t, a, n, i); } return t; }); } -var Fa = Object.prototype; -function gi(r) { - var t = r && r.constructor, e = typeof t == "function" && t.prototype || Fa; +var Aa = Object.prototype; +function Qs(r) { + var t = r && r.constructor, e = typeof t == "function" && t.prototype || Aa; return r === e; } -function Ha(r, t) { +function wa(r, t) { for (var e = -1, n = Array(r); ++e < r; ) n[e] = t(e); return n; } -var za = "[object Arguments]"; -function Di(r) { - return oe(r) && hn(r) == za; +var Na = "[object Arguments]"; +function wi(r) { + return ne(r) && nn(r) == Na; } -var zl = Object.prototype, Ka = zl.hasOwnProperty, Ga = zl.propertyIsEnumerable, zs = Di(/* @__PURE__ */ function() { +var ql = Object.prototype, Ta = ql.hasOwnProperty, xa = ql.propertyIsEnumerable, Bs = wi(/* @__PURE__ */ (function() { return arguments; -}()) ? Di : function(r) { - return oe(r) && Ka.call(r, "callee") && !Ga.call(r, "callee"); +})()) ? wi : function(r) { + return ne(r) && Ta.call(r, "callee") && !xa.call(r, "callee"); }; -function Va() { +function La() { return !1; } -var Kl = typeof exports == "object" && exports && !exports.nodeType && exports, ji = Kl && typeof module == "object" && module && !module.nodeType && module, Wa = ji && ji.exports === Kl, $i = Wa ? ee.Buffer : void 0, Za = $i ? $i.isBuffer : void 0, jn = Za || Va, Ya = "[object Arguments]", Xa = "[object Array]", Qa = "[object Boolean]", Ja = "[object Date]", tc = "[object Error]", ec = "[object Function]", nc = "[object Map]", rc = "[object Number]", sc = "[object Object]", ic = "[object RegExp]", lc = "[object Set]", oc = "[object String]", ac = "[object WeakMap]", cc = "[object ArrayBuffer]", uc = "[object DataView]", hc = "[object Float32Array]", fc = "[object Float64Array]", dc = "[object Int8Array]", gc = "[object Int16Array]", pc = "[object Int32Array]", mc = "[object Uint8Array]", bc = "[object Uint8ClampedArray]", yc = "[object Uint16Array]", vc = "[object Uint32Array]", lt = {}; -lt[hc] = lt[fc] = lt[dc] = lt[gc] = lt[pc] = lt[mc] = lt[bc] = lt[yc] = lt[vc] = !0; -lt[Ya] = lt[Xa] = lt[cc] = lt[Qa] = lt[uc] = lt[Ja] = lt[tc] = lt[ec] = lt[nc] = lt[rc] = lt[sc] = lt[ic] = lt[lc] = lt[oc] = lt[ac] = !1; -function Ec(r) { - return oe(r) && Hl(r.length) && !!lt[hn(r)]; +var Ol = typeof exports == "object" && exports && !exports.nodeType && exports, Ni = Ol && typeof module == "object" && module && !module.nodeType && module, Sa = Ni && Ni.exports === Ol, Ti = Sa ? Zt.Buffer : void 0, _a = Ti ? Ti.isBuffer : void 0, Cn = _a || La, Ca = "[object Arguments]", qa = "[object Array]", Oa = "[object Boolean]", Ra = "[object Date]", Ia = "[object Error]", ka = "[object Function]", Ba = "[object Map]", Ma = "[object Number]", Da = "[object Object]", ja = "[object RegExp]", Pa = "[object Set]", $a = "[object String]", Ua = "[object WeakMap]", Fa = "[object ArrayBuffer]", Ha = "[object DataView]", za = "[object Float32Array]", Ka = "[object Float64Array]", Ga = "[object Int8Array]", Va = "[object Int16Array]", Wa = "[object Int32Array]", Za = "[object Uint8Array]", Ya = "[object Uint8ClampedArray]", Xa = "[object Uint16Array]", Qa = "[object Uint32Array]", it = {}; +it[za] = it[Ka] = it[Ga] = it[Va] = it[Wa] = it[Za] = it[Ya] = it[Xa] = it[Qa] = !0; +it[Ca] = it[qa] = it[Fa] = it[Oa] = it[Ha] = it[Ra] = it[Ia] = it[ka] = it[Ba] = it[Ma] = it[Da] = it[ja] = it[Pa] = it[$a] = it[Ua] = !1; +function Ja(r) { + return ne(r) && Cl(r.length) && !!it[nn(r)]; } -function pi(r) { +function Js(r) { return function(t) { return r(t); }; } -var Gl = typeof exports == "object" && exports && !exports.nodeType && exports, kn = Gl && typeof module == "object" && module && !module.nodeType && module, Ac = kn && kn.exports === Gl, Os = Ac && jl.process, ln = function() { +var Rl = typeof exports == "object" && exports && !exports.nodeType && exports, Sn = Rl && typeof module == "object" && module && !module.nodeType && module, tc = Sn && Sn.exports === Rl, As = tc && Tl.process, tn = (function() { try { - var r = kn && kn.require && kn.require("util").types; - return r || Os && Os.binding && Os.binding("util"); + var r = Sn && Sn.require && Sn.require("util").types; + return r || As && As.binding && As.binding("util"); } catch { } -}(), Pi = ln && ln.isTypedArray, mi = Pi ? pi(Pi) : Ec, wc = Object.prototype, Nc = wc.hasOwnProperty; -function Vl(r, t) { - var e = Oe(r), n = !e && zs(r), s = !e && !n && jn(r), i = !e && !n && !s && mi(r), o = e || n || s || i, a = o ? Ha(r.length, String) : [], u = a.length; +})(), xi = tn && tn.isTypedArray, ti = xi ? Js(xi) : Ja, ec = Object.prototype, nc = ec.hasOwnProperty; +function Il(r, t) { + var e = xe(r), n = !e && Bs(r), s = !e && !n && Cn(r), i = !e && !n && !s && ti(r), o = e || n || s || i, a = o ? wa(r.length, String) : [], u = a.length; for (var h in r) - (t || Nc.call(r, h)) && !(o && // Safari 9 has enumerable `arguments.length` in strict mode. + (t || nc.call(r, h)) && !(o && // Safari 9 has enumerable `arguments.length` in strict mode. (h == "length" || // Node.js 0.10 has enumerable non-index properties on buffers. s && (h == "offset" || h == "parent") || // PhantomJS 2 has enumerable non-index properties on typed arrays. i && (h == "buffer" || h == "byteLength" || h == "byteOffset") || // Skip index properties. - Ul(h, u))) && a.push(h); + Sl(h, u))) && a.push(h); return a; } -function Wl(r, t) { +function kl(r, t) { return function(e) { return r(t(e)); }; } -var Tc = Wl(Object.keys, Object), xc = Object.prototype, Lc = xc.hasOwnProperty; -function Sc(r) { - if (!gi(r)) - return Tc(r); +var rc = kl(Object.keys, Object), sc = Object.prototype, ic = sc.hasOwnProperty; +function lc(r) { + if (!Qs(r)) + return rc(r); var t = []; for (var e in Object(r)) - Lc.call(r, e) && e != "constructor" && t.push(e); + ic.call(r, e) && e != "constructor" && t.push(e); return t; } -function _c(r) { - return Ir(r) ? Vl(r) : Sc(r); +function oc(r) { + return Nr(r) ? Il(r) : lc(r); } -function qc(r) { +function ac(r) { var t = []; if (r != null) for (var e in Object(r)) t.push(e); return t; } -var Oc = Object.prototype, Cc = Oc.hasOwnProperty; -function Ic(r) { - if (!Ae(r)) - return qc(r); - var t = gi(r), e = []; +var cc = Object.prototype, uc = cc.hasOwnProperty; +function hc(r) { + if (!pe(r)) + return ac(r); + var t = Qs(r), e = []; for (var n in r) - n == "constructor" && (t || !Cc.call(r, n)) || e.push(n); + n == "constructor" && (t || !uc.call(r, n)) || e.push(n); return e; } -function Zl(r) { - return Ir(r) ? Vl(r, !0) : Ic(r); +function Bl(r) { + return Nr(r) ? Il(r, !0) : hc(r); } -var $n = ke(Object, "create"); -function Rc() { - this.__data__ = $n ? $n(null) : {}, this.size = 0; +var qn = qe(Object, "create"); +function fc() { + this.__data__ = qn ? qn(null) : {}, this.size = 0; } -function kc(r) { +function dc(r) { var t = this.has(r) && delete this.__data__[r]; return this.size -= t ? 1 : 0, t; } -var Bc = "__lodash_hash_undefined__", Mc = Object.prototype, Dc = Mc.hasOwnProperty; -function jc(r) { +var gc = "__lodash_hash_undefined__", pc = Object.prototype, mc = pc.hasOwnProperty; +function bc(r) { var t = this.__data__; - if ($n) { + if (qn) { var e = t[r]; - return e === Bc ? void 0 : e; + return e === gc ? void 0 : e; } - return Dc.call(t, r) ? t[r] : void 0; + return mc.call(t, r) ? t[r] : void 0; } -var $c = Object.prototype, Pc = $c.hasOwnProperty; -function Uc(r) { +var yc = Object.prototype, vc = yc.hasOwnProperty; +function Ec(r) { var t = this.__data__; - return $n ? t[r] !== void 0 : Pc.call(t, r); + return qn ? t[r] !== void 0 : vc.call(t, r); } -var Fc = "__lodash_hash_undefined__"; -function Hc(r, t) { +var Ac = "__lodash_hash_undefined__"; +function wc(r, t) { var e = this.__data__; - return this.size += this.has(r) ? 0 : 1, e[r] = $n && t === void 0 ? Fc : t, this; + return this.size += this.has(r) ? 0 : 1, e[r] = qn && t === void 0 ? Ac : t, this; } -function Ce(r) { +function Le(r) { var t = -1, e = r == null ? 0 : r.length; for (this.clear(); ++t < e; ) { var n = r[t]; this.set(n[0], n[1]); } } -Ce.prototype.clear = Rc; -Ce.prototype.delete = kc; -Ce.prototype.get = jc; -Ce.prototype.has = Uc; -Ce.prototype.set = Hc; -function zc() { +Le.prototype.clear = fc; +Le.prototype.delete = dc; +Le.prototype.get = bc; +Le.prototype.has = Ec; +Le.prototype.set = wc; +function Nc() { this.__data__ = [], this.size = 0; } -function Rr(r, t) { +function Tr(r, t) { for (var e = r.length; e--; ) - if (Kn(r[e][0], t)) + if (Bn(r[e][0], t)) return e; return -1; } -var Kc = Array.prototype, Gc = Kc.splice; -function Vc(r) { - var t = this.__data__, e = Rr(t, r); +var Tc = Array.prototype, xc = Tc.splice; +function Lc(r) { + var t = this.__data__, e = Tr(t, r); if (e < 0) return !1; var n = t.length - 1; - return e == n ? t.pop() : Gc.call(t, e, 1), --this.size, !0; + return e == n ? t.pop() : xc.call(t, e, 1), --this.size, !0; } -function Wc(r) { - var t = this.__data__, e = Rr(t, r); +function Sc(r) { + var t = this.__data__, e = Tr(t, r); return e < 0 ? void 0 : t[e][1]; } -function Zc(r) { - return Rr(this.__data__, r) > -1; +function _c(r) { + return Tr(this.__data__, r) > -1; } -function Yc(r, t) { - var e = this.__data__, n = Rr(e, r); +function Cc(r, t) { + var e = this.__data__, n = Tr(e, r); return n < 0 ? (++this.size, e.push([r, t])) : e[n][1] = t, this; } -function ue(r) { +function se(r) { var t = -1, e = r == null ? 0 : r.length; for (this.clear(); ++t < e; ) { var n = r[t]; this.set(n[0], n[1]); } } -ue.prototype.clear = zc; -ue.prototype.delete = Vc; -ue.prototype.get = Wc; -ue.prototype.has = Zc; -ue.prototype.set = Yc; -var Pn = ke(ee, "Map"); -function Xc() { +se.prototype.clear = Nc; +se.prototype.delete = Lc; +se.prototype.get = Sc; +se.prototype.has = _c; +se.prototype.set = Cc; +var On = qe(Zt, "Map"); +function qc() { this.size = 0, this.__data__ = { - hash: new Ce(), - map: new (Pn || ue)(), - string: new Ce() + hash: new Le(), + map: new (On || se)(), + string: new Le() }; } -function Qc(r) { +function Oc(r) { var t = typeof r; return t == "string" || t == "number" || t == "symbol" || t == "boolean" ? r !== "__proto__" : r === null; } -function kr(r, t) { +function xr(r, t) { var e = r.__data__; - return Qc(t) ? e[typeof t == "string" ? "string" : "hash"] : e.map; + return Oc(t) ? e[typeof t == "string" ? "string" : "hash"] : e.map; } -function Jc(r) { - var t = kr(this, r).delete(r); +function Rc(r) { + var t = xr(this, r).delete(r); return this.size -= t ? 1 : 0, t; } -function tu(r) { - return kr(this, r).get(r); +function Ic(r) { + return xr(this, r).get(r); } -function eu(r) { - return kr(this, r).has(r); +function kc(r) { + return xr(this, r).has(r); } -function nu(r, t) { - var e = kr(this, r), n = e.size; +function Bc(r, t) { + var e = xr(this, r), n = e.size; return e.set(r, t), this.size += e.size == n ? 0 : 1, this; } -function Be(r) { +function Oe(r) { var t = -1, e = r == null ? 0 : r.length; for (this.clear(); ++t < e; ) { var n = r[t]; this.set(n[0], n[1]); } } -Be.prototype.clear = Xc; -Be.prototype.delete = Jc; -Be.prototype.get = tu; -Be.prototype.has = eu; -Be.prototype.set = nu; -function ru(r, t) { +Oe.prototype.clear = qc; +Oe.prototype.delete = Rc; +Oe.prototype.get = Ic; +Oe.prototype.has = kc; +Oe.prototype.set = Bc; +function Mc(r, t) { for (var e = -1, n = t.length, s = r.length; ++e < n; ) r[s + e] = t[e]; return r; } -var Yl = Wl(Object.getPrototypeOf, Object), su = "[object Object]", iu = Function.prototype, lu = Object.prototype, Xl = iu.toString, ou = lu.hasOwnProperty, au = Xl.call(Object); -function cu(r) { - if (!oe(r) || hn(r) != su) +var Ml = kl(Object.getPrototypeOf, Object), Dc = "[object Object]", jc = Function.prototype, Pc = Object.prototype, Dl = jc.toString, $c = Pc.hasOwnProperty, Uc = Dl.call(Object); +function Fc(r) { + if (!ne(r) || nn(r) != Dc) return !1; - var t = Yl(r); + var t = Ml(r); if (t === null) return !0; - var e = ou.call(t, "constructor") && t.constructor; - return typeof e == "function" && e instanceof e && Xl.call(e) == au; + var e = $c.call(t, "constructor") && t.constructor; + return typeof e == "function" && e instanceof e && Dl.call(e) == Uc; } -function uu() { - this.__data__ = new ue(), this.size = 0; +function Hc() { + this.__data__ = new se(), this.size = 0; } -function hu(r) { +function zc(r) { var t = this.__data__, e = t.delete(r); return this.size = t.size, e; } -function fu(r) { +function Kc(r) { return this.__data__.get(r); } -function du(r) { +function Gc(r) { return this.__data__.has(r); } -var gu = 200; -function pu(r, t) { +var Vc = 200; +function Wc(r, t) { var e = this.__data__; - if (e instanceof ue) { + if (e instanceof se) { var n = e.__data__; - if (!Pn || n.length < gu - 1) + if (!On || n.length < Vc - 1) return n.push([r, t]), this.size = ++e.size, this; - e = this.__data__ = new Be(n); + e = this.__data__ = new Oe(n); } return e.set(r, t), this.size = e.size, this; } -function Xt(r) { - var t = this.__data__ = new ue(r); +function Vt(r) { + var t = this.__data__ = new se(r); this.size = t.size; } -Xt.prototype.clear = uu; -Xt.prototype.delete = hu; -Xt.prototype.get = fu; -Xt.prototype.has = du; -Xt.prototype.set = pu; -var Ql = typeof exports == "object" && exports && !exports.nodeType && exports, Ui = Ql && typeof module == "object" && module && !module.nodeType && module, mu = Ui && Ui.exports === Ql, Fi = mu ? ee.Buffer : void 0, Hi = Fi ? Fi.allocUnsafe : void 0; -function Jl(r, t) { +Vt.prototype.clear = Hc; +Vt.prototype.delete = zc; +Vt.prototype.get = Kc; +Vt.prototype.has = Gc; +Vt.prototype.set = Wc; +var jl = typeof exports == "object" && exports && !exports.nodeType && exports, Li = jl && typeof module == "object" && module && !module.nodeType && module, Zc = Li && Li.exports === jl, Si = Zc ? Zt.Buffer : void 0, _i = Si ? Si.allocUnsafe : void 0; +function Pl(r, t) { if (t) return r.slice(); - var e = r.length, n = Hi ? Hi(e) : new r.constructor(e); + var e = r.length, n = _i ? _i(e) : new r.constructor(e); return r.copy(n), n; } -function bu(r, t) { +function Yc(r, t) { for (var e = -1, n = r == null ? 0 : r.length, s = 0, i = []; ++e < n; ) { var o = r[e]; t(o, e, r) && (i[s++] = o); } return i; } -function yu() { +function Xc() { return []; } -var vu = Object.prototype, Eu = vu.propertyIsEnumerable, zi = Object.getOwnPropertySymbols, Au = zi ? function(r) { - return r == null ? [] : (r = Object(r), bu(zi(r), function(t) { - return Eu.call(r, t); +var Qc = Object.prototype, Jc = Qc.propertyIsEnumerable, Ci = Object.getOwnPropertySymbols, tu = Ci ? function(r) { + return r == null ? [] : (r = Object(r), Yc(Ci(r), function(t) { + return Jc.call(r, t); })); -} : yu; -function wu(r, t, e) { +} : Xc; +function eu(r, t, e) { var n = t(r); - return Oe(r) ? n : ru(n, e(r)); + return xe(r) ? n : Mc(n, e(r)); } -function Ks(r) { - return wu(r, _c, Au); +function Ms(r) { + return eu(r, oc, tu); } -var Gs = ke(ee, "DataView"), Vs = ke(ee, "Promise"), Ws = ke(ee, "Set"), Ki = "[object Map]", Nu = "[object Object]", Gi = "[object Promise]", Vi = "[object Set]", Wi = "[object WeakMap]", Zi = "[object DataView]", Tu = Re(Gs), xu = Re(Pn), Lu = Re(Vs), Su = Re(Ws), _u = Re(Hs), jt = hn; -(Gs && jt(new Gs(new ArrayBuffer(1))) != Zi || Pn && jt(new Pn()) != Ki || Vs && jt(Vs.resolve()) != Gi || Ws && jt(new Ws()) != Vi || Hs && jt(new Hs()) != Wi) && (jt = function(r) { - var t = hn(r), e = t == Nu ? r.constructor : void 0, n = e ? Re(e) : ""; +var Ds = qe(Zt, "DataView"), js = qe(Zt, "Promise"), Ps = qe(Zt, "Set"), qi = "[object Map]", nu = "[object Object]", Oi = "[object Promise]", Ri = "[object Set]", Ii = "[object WeakMap]", ki = "[object DataView]", ru = Ce(Ds), su = Ce(On), iu = Ce(js), lu = Ce(Ps), ou = Ce(ks), Mt = nn; +(Ds && Mt(new Ds(new ArrayBuffer(1))) != ki || On && Mt(new On()) != qi || js && Mt(js.resolve()) != Oi || Ps && Mt(new Ps()) != Ri || ks && Mt(new ks()) != Ii) && (Mt = function(r) { + var t = nn(r), e = t == nu ? r.constructor : void 0, n = e ? Ce(e) : ""; if (n) switch (n) { - case Tu: - return Zi; - case xu: - return Ki; - case Lu: - return Gi; - case Su: - return Vi; - case _u: - return Wi; + case ru: + return ki; + case su: + return qi; + case iu: + return Oi; + case lu: + return Ri; + case ou: + return Ii; } return t; }); -var qu = Object.prototype, Ou = qu.hasOwnProperty; -function Cu(r) { +var au = Object.prototype, cu = au.hasOwnProperty; +function uu(r) { var t = r.length, e = new r.constructor(t); - return t && typeof r[0] == "string" && Ou.call(r, "index") && (e.index = r.index, e.input = r.input), e; + return t && typeof r[0] == "string" && cu.call(r, "index") && (e.index = r.index, e.input = r.input), e; } -var qr = ee.Uint8Array; -function bi(r) { +var yr = Zt.Uint8Array; +function ei(r) { var t = new r.constructor(r.byteLength); - return new qr(t).set(new qr(r)), t; + return new yr(t).set(new yr(r)), t; } -function Iu(r, t) { - var e = bi(r.buffer); +function hu(r, t) { + var e = ei(r.buffer); return new r.constructor(e, r.byteOffset, r.byteLength); } -var Ru = /\w*$/; -function ku(r) { - var t = new r.constructor(r.source, Ru.exec(r)); +var fu = /\w*$/; +function du(r) { + var t = new r.constructor(r.source, fu.exec(r)); return t.lastIndex = r.lastIndex, t; } -var Yi = Ee ? Ee.prototype : void 0, Xi = Yi ? Yi.valueOf : void 0; -function Bu(r) { - return Xi ? Object(Xi.call(r)) : {}; +var Bi = ge ? ge.prototype : void 0, Mi = Bi ? Bi.valueOf : void 0; +function gu(r) { + return Mi ? Object(Mi.call(r)) : {}; } -function to(r, t) { - var e = t ? bi(r.buffer) : r.buffer; +function $l(r, t) { + var e = t ? ei(r.buffer) : r.buffer; return new r.constructor(e, r.byteOffset, r.length); } -var Mu = "[object Boolean]", Du = "[object Date]", ju = "[object Map]", $u = "[object Number]", Pu = "[object RegExp]", Uu = "[object Set]", Fu = "[object String]", Hu = "[object Symbol]", zu = "[object ArrayBuffer]", Ku = "[object DataView]", Gu = "[object Float32Array]", Vu = "[object Float64Array]", Wu = "[object Int8Array]", Zu = "[object Int16Array]", Yu = "[object Int32Array]", Xu = "[object Uint8Array]", Qu = "[object Uint8ClampedArray]", Ju = "[object Uint16Array]", th = "[object Uint32Array]"; -function eh(r, t, e) { +var pu = "[object Boolean]", mu = "[object Date]", bu = "[object Map]", yu = "[object Number]", vu = "[object RegExp]", Eu = "[object Set]", Au = "[object String]", wu = "[object Symbol]", Nu = "[object ArrayBuffer]", Tu = "[object DataView]", xu = "[object Float32Array]", Lu = "[object Float64Array]", Su = "[object Int8Array]", _u = "[object Int16Array]", Cu = "[object Int32Array]", qu = "[object Uint8Array]", Ou = "[object Uint8ClampedArray]", Ru = "[object Uint16Array]", Iu = "[object Uint32Array]"; +function ku(r, t, e) { var n = r.constructor; switch (t) { - case zu: - return bi(r); - case Mu: - case Du: + case Nu: + return ei(r); + case pu: + case mu: return new n(+r); - case Ku: - return Iu(r); - case Gu: - case Vu: - case Wu: - case Zu: - case Yu: - case Xu: - case Qu: - case Ju: - case th: - return to(r, e); - case ju: + case Tu: + return hu(r); + case xu: + case Lu: + case Su: + case _u: + case Cu: + case qu: + case Ou: + case Ru: + case Iu: + return $l(r, e); + case bu: return new n(); - case $u: - case Fu: + case yu: + case Au: return new n(r); - case Pu: - return ku(r); - case Uu: + case vu: + return du(r); + case Eu: return new n(); - case Hu: - return Bu(r); + case wu: + return gu(r); } } -function eo(r) { - return typeof r.constructor == "function" && !gi(r) ? Aa(Yl(r)) : {}; +function Ul(r) { + return typeof r.constructor == "function" && !Qs(r) ? ta(Ml(r)) : {}; } -var nh = "[object Map]"; -function rh(r) { - return oe(r) && jt(r) == nh; +var Bu = "[object Map]"; +function Mu(r) { + return ne(r) && Mt(r) == Bu; } -var Qi = ln && ln.isMap, sh = Qi ? pi(Qi) : rh, ih = "[object Set]"; -function lh(r) { - return oe(r) && jt(r) == ih; +var Di = tn && tn.isMap, Du = Di ? Js(Di) : Mu, ju = "[object Set]"; +function Pu(r) { + return ne(r) && Mt(r) == ju; } -var Ji = ln && ln.isSet, oh = Ji ? pi(Ji) : lh, ah = 1, no = "[object Arguments]", ch = "[object Array]", uh = "[object Boolean]", hh = "[object Date]", fh = "[object Error]", ro = "[object Function]", dh = "[object GeneratorFunction]", gh = "[object Map]", ph = "[object Number]", so = "[object Object]", mh = "[object RegExp]", bh = "[object Set]", yh = "[object String]", vh = "[object Symbol]", Eh = "[object WeakMap]", Ah = "[object ArrayBuffer]", wh = "[object DataView]", Nh = "[object Float32Array]", Th = "[object Float64Array]", xh = "[object Int8Array]", Lh = "[object Int16Array]", Sh = "[object Int32Array]", _h = "[object Uint8Array]", qh = "[object Uint8ClampedArray]", Oh = "[object Uint16Array]", Ch = "[object Uint32Array]", st = {}; -st[no] = st[ch] = st[Ah] = st[wh] = st[uh] = st[hh] = st[Nh] = st[Th] = st[xh] = st[Lh] = st[Sh] = st[gh] = st[ph] = st[so] = st[mh] = st[bh] = st[yh] = st[vh] = st[_h] = st[qh] = st[Oh] = st[Ch] = !0; -st[fh] = st[ro] = st[Eh] = !1; -function xr(r, t, e, n, s, i) { - var o, a = t & ah; +var ji = tn && tn.isSet, $u = ji ? Js(ji) : Pu, Uu = 1, Fl = "[object Arguments]", Fu = "[object Array]", Hu = "[object Boolean]", zu = "[object Date]", Ku = "[object Error]", Hl = "[object Function]", Gu = "[object GeneratorFunction]", Vu = "[object Map]", Wu = "[object Number]", zl = "[object Object]", Zu = "[object RegExp]", Yu = "[object Set]", Xu = "[object String]", Qu = "[object Symbol]", Ju = "[object WeakMap]", th = "[object ArrayBuffer]", eh = "[object DataView]", nh = "[object Float32Array]", rh = "[object Float64Array]", sh = "[object Int8Array]", ih = "[object Int16Array]", lh = "[object Int32Array]", oh = "[object Uint8Array]", ah = "[object Uint8ClampedArray]", ch = "[object Uint16Array]", uh = "[object Uint32Array]", rt = {}; +rt[Fl] = rt[Fu] = rt[th] = rt[eh] = rt[Hu] = rt[zu] = rt[nh] = rt[rh] = rt[sh] = rt[ih] = rt[lh] = rt[Vu] = rt[Wu] = rt[zl] = rt[Zu] = rt[Yu] = rt[Xu] = rt[Qu] = rt[oh] = rt[ah] = rt[ch] = rt[uh] = !0; +rt[Ku] = rt[Hl] = rt[Ju] = !1; +function mr(r, t, e, n, s, i) { + var o, a = t & Uu; if (o !== void 0) return o; - if (!Ae(r)) + if (!pe(r)) return r; - var u = Oe(r); + var u = xe(r); if (u) - o = Cu(r); + o = uu(r); else { - var h = jt(r), p = h == ro || h == dh; - if (jn(r)) - return Jl(r, a); - if (h == so || h == no || p && !s) - o = p ? {} : eo(r); + var h = Mt(r), p = h == Hl || h == Gu; + if (Cn(r)) + return Pl(r, a); + if (h == zl || h == Fl || p && !s) + o = p ? {} : Ul(r); else { - if (!st[h]) + if (!rt[h]) return s ? r : {}; - o = eh(r, h, a); + o = ku(r, h, a); } } - i || (i = new Xt()); + i || (i = new Vt()); var v = i.get(r); if (v) return v; - i.set(r, o), oh(r) ? r.forEach(function(y) { - o.add(xr(y, t, e, y, r, i)); - }) : sh(r) && r.forEach(function(y, A) { - o.set(A, xr(y, t, e, A, r, i)); + i.set(r, o), $u(r) ? r.forEach(function(y) { + o.add(mr(y, t, e, y, r, i)); + }) : Du(r) && r.forEach(function(y, w) { + o.set(w, mr(y, t, e, w, r, i)); }); - var f = Ks, m = u ? void 0 : f(r); - return Ca(m || r, function(y, A) { - m && (A = y, y = r[A]), Fl(o, A, xr(y, t, e, A, r, i)); + var f = Ms, m = u ? void 0 : f(r); + return ua(m || r, function(y, w) { + m && (w = y, y = r[w]), _l(o, w, mr(y, t, e, w, r, i)); }), o; } -var Ih = 1, Rh = 4; -function nn(r) { - return xr(r, Ih | Rh); +var hh = 1, fh = 4; +function Xe(r) { + return mr(r, hh | fh); } -var kh = "__lodash_hash_undefined__"; -function Bh(r) { - return this.__data__.set(r, kh), this; +var dh = "__lodash_hash_undefined__"; +function gh(r) { + return this.__data__.set(r, dh), this; } -function Mh(r) { +function ph(r) { return this.__data__.has(r); } -function Or(r) { +function vr(r) { var t = -1, e = r == null ? 0 : r.length; - for (this.__data__ = new Be(); ++t < e; ) + for (this.__data__ = new Oe(); ++t < e; ) this.add(r[t]); } -Or.prototype.add = Or.prototype.push = Bh; -Or.prototype.has = Mh; -function Dh(r, t) { +vr.prototype.add = vr.prototype.push = gh; +vr.prototype.has = ph; +function mh(r, t) { for (var e = -1, n = r == null ? 0 : r.length; ++e < n; ) if (t(r[e], e, r)) return !0; return !1; } -function jh(r, t) { +function bh(r, t) { return r.has(t); } -var $h = 1, Ph = 2; -function io(r, t, e, n, s, i) { - var o = e & $h, a = r.length, u = t.length; +var yh = 1, vh = 2; +function Kl(r, t, e, n, s, i) { + var o = e & yh, a = r.length, u = t.length; if (a != u && !(o && u > a)) return !1; var h = i.get(r), p = i.get(t); if (h && p) return h == t && p == r; - var v = -1, f = !0, m = e & Ph ? new Or() : void 0; + var v = -1, f = !0, m = e & vh ? new vr() : void 0; for (i.set(r, t), i.set(t, r); ++v < a; ) { - var y = r[v], A = t[v]; + var y = r[v], w = t[v]; if (n) - var N = o ? n(A, y, v, t, r, i) : n(y, A, v, r, t, i); + var N = o ? n(w, y, v, t, r, i) : n(y, w, v, r, t, i); if (N !== void 0) { if (N) continue; @@ -710,125 +698,125 @@ function io(r, t, e, n, s, i) { break; } if (m) { - if (!Dh(t, function(x, C) { - if (!jh(m, C) && (y === x || s(y, x, e, n, i))) - return m.push(C); + if (!mh(t, function(x, q) { + if (!bh(m, q) && (y === x || s(y, x, e, n, i))) + return m.push(q); })) { f = !1; break; } - } else if (!(y === A || s(y, A, e, n, i))) { + } else if (!(y === w || s(y, w, e, n, i))) { f = !1; break; } } return i.delete(r), i.delete(t), f; } -function Uh(r) { +function Eh(r) { var t = -1, e = Array(r.size); return r.forEach(function(n, s) { e[++t] = [s, n]; }), e; } -function Fh(r) { +function Ah(r) { var t = -1, e = Array(r.size); return r.forEach(function(n) { e[++t] = n; }), e; } -var Hh = 1, zh = 2, Kh = "[object Boolean]", Gh = "[object Date]", Vh = "[object Error]", Wh = "[object Map]", Zh = "[object Number]", Yh = "[object RegExp]", Xh = "[object Set]", Qh = "[object String]", Jh = "[object Symbol]", tf = "[object ArrayBuffer]", ef = "[object DataView]", tl = Ee ? Ee.prototype : void 0, Cs = tl ? tl.valueOf : void 0; -function nf(r, t, e, n, s, i, o) { +var wh = 1, Nh = 2, Th = "[object Boolean]", xh = "[object Date]", Lh = "[object Error]", Sh = "[object Map]", _h = "[object Number]", Ch = "[object RegExp]", qh = "[object Set]", Oh = "[object String]", Rh = "[object Symbol]", Ih = "[object ArrayBuffer]", kh = "[object DataView]", Pi = ge ? ge.prototype : void 0, ws = Pi ? Pi.valueOf : void 0; +function Bh(r, t, e, n, s, i, o) { switch (e) { - case ef: + case kh: if (r.byteLength != t.byteLength || r.byteOffset != t.byteOffset) return !1; r = r.buffer, t = t.buffer; - case tf: - return !(r.byteLength != t.byteLength || !i(new qr(r), new qr(t))); - case Kh: - case Gh: - case Zh: - return Kn(+r, +t); - case Vh: + case Ih: + return !(r.byteLength != t.byteLength || !i(new yr(r), new yr(t))); + case Th: + case xh: + case _h: + return Bn(+r, +t); + case Lh: return r.name == t.name && r.message == t.message; - case Yh: - case Qh: + case Ch: + case Oh: return r == t + ""; - case Wh: - var a = Uh; - case Xh: - var u = n & Hh; - if (a || (a = Fh), r.size != t.size && !u) + case Sh: + var a = Eh; + case qh: + var u = n & wh; + if (a || (a = Ah), r.size != t.size && !u) return !1; var h = o.get(r); if (h) return h == t; - n |= zh, o.set(r, t); - var p = io(a(r), a(t), n, s, i, o); + n |= Nh, o.set(r, t); + var p = Kl(a(r), a(t), n, s, i, o); return o.delete(r), p; - case Jh: - if (Cs) - return Cs.call(r) == Cs.call(t); + case Rh: + if (ws) + return ws.call(r) == ws.call(t); } return !1; } -var rf = 1, sf = Object.prototype, lf = sf.hasOwnProperty; -function of(r, t, e, n, s, i) { - var o = e & rf, a = Ks(r), u = a.length, h = Ks(t), p = h.length; +var Mh = 1, Dh = Object.prototype, jh = Dh.hasOwnProperty; +function Ph(r, t, e, n, s, i) { + var o = e & Mh, a = Ms(r), u = a.length, h = Ms(t), p = h.length; if (u != p && !o) return !1; for (var v = u; v--; ) { var f = a[v]; - if (!(o ? f in t : lf.call(t, f))) + if (!(o ? f in t : jh.call(t, f))) return !1; } var m = i.get(r), y = i.get(t); if (m && y) return m == t && y == r; - var A = !0; + var w = !0; i.set(r, t), i.set(t, r); for (var N = o; ++v < u; ) { f = a[v]; - var x = r[f], C = t[f]; + var x = r[f], q = t[f]; if (n) - var M = o ? n(C, x, f, t, r, i) : n(x, C, f, r, t, i); - if (!(M === void 0 ? x === C || s(x, C, e, n, i) : M)) { - A = !1; + var B = o ? n(q, x, f, t, r, i) : n(x, q, f, r, t, i); + if (!(B === void 0 ? x === q || s(x, q, e, n, i) : B)) { + w = !1; break; } N || (N = f == "constructor"); } - if (A && !N) { - var z = r.constructor, U = t.constructor; - z != U && "constructor" in r && "constructor" in t && !(typeof z == "function" && z instanceof z && typeof U == "function" && U instanceof U) && (A = !1); + if (w && !N) { + var H = r.constructor, $ = t.constructor; + H != $ && "constructor" in r && "constructor" in t && !(typeof H == "function" && H instanceof H && typeof $ == "function" && $ instanceof $) && (w = !1); } - return i.delete(r), i.delete(t), A; + return i.delete(r), i.delete(t), w; } -var af = 1, el = "[object Arguments]", nl = "[object Array]", pr = "[object Object]", cf = Object.prototype, rl = cf.hasOwnProperty; -function uf(r, t, e, n, s, i) { - var o = Oe(r), a = Oe(t), u = o ? nl : jt(r), h = a ? nl : jt(t); - u = u == el ? pr : u, h = h == el ? pr : h; - var p = u == pr, v = h == pr, f = u == h; - if (f && jn(r)) { - if (!jn(t)) +var $h = 1, $i = "[object Arguments]", Ui = "[object Array]", ir = "[object Object]", Uh = Object.prototype, Fi = Uh.hasOwnProperty; +function Fh(r, t, e, n, s, i) { + var o = xe(r), a = xe(t), u = o ? Ui : Mt(r), h = a ? Ui : Mt(t); + u = u == $i ? ir : u, h = h == $i ? ir : h; + var p = u == ir, v = h == ir, f = u == h; + if (f && Cn(r)) { + if (!Cn(t)) return !1; o = !0, p = !1; } if (f && !p) - return i || (i = new Xt()), o || mi(r) ? io(r, t, e, n, s, i) : nf(r, t, u, e, n, s, i); - if (!(e & af)) { - var m = p && rl.call(r, "__wrapped__"), y = v && rl.call(t, "__wrapped__"); + return i || (i = new Vt()), o || ti(r) ? Kl(r, t, e, n, s, i) : Bh(r, t, u, e, n, s, i); + if (!(e & $h)) { + var m = p && Fi.call(r, "__wrapped__"), y = v && Fi.call(t, "__wrapped__"); if (m || y) { - var A = m ? r.value() : r, N = y ? t.value() : t; - return i || (i = new Xt()), s(A, N, e, n, i); + var w = m ? r.value() : r, N = y ? t.value() : t; + return i || (i = new Vt()), s(w, N, e, n, i); } } - return f ? (i || (i = new Xt()), of(r, t, e, n, s, i)) : !1; + return f ? (i || (i = new Vt()), Ph(r, t, e, n, s, i)) : !1; } -function lo(r, t, e, n, s) { - return r === t ? !0 : r == null || t == null || !oe(r) && !oe(t) ? r !== r && t !== t : uf(r, t, e, n, lo, s); +function Gl(r, t, e, n, s) { + return r === t ? !0 : r == null || t == null || !ne(r) && !ne(t) ? r !== r && t !== t : Fh(r, t, e, n, Gl, s); } -function hf(r) { +function Hh(r) { return function(t, e, n) { for (var s = -1, i = Object(t), o = n(t), a = o.length; a--; ) { var u = o[++s]; @@ -838,57 +826,57 @@ function hf(r) { return t; }; } -var ff = hf(); -function Zs(r, t, e) { - (e !== void 0 && !Kn(r[t], e) || e === void 0 && !(t in r)) && di(r, t, e); +var zh = Hh(); +function $s(r, t, e) { + (e !== void 0 && !Bn(r[t], e) || e === void 0 && !(t in r)) && Xs(r, t, e); } -function df(r) { - return oe(r) && Ir(r); +function Kh(r) { + return ne(r) && Nr(r); } -function Ys(r, t) { +function Us(r, t) { if (!(t === "constructor" && typeof r[t] == "function") && t != "__proto__") return r[t]; } -function gf(r) { - return Ma(r, Zl(r)); +function Gh(r) { + return pa(r, Bl(r)); } -function pf(r, t, e, n, s, i, o) { - var a = Ys(r, e), u = Ys(t, e), h = o.get(u); +function Vh(r, t, e, n, s, i, o) { + var a = Us(r, e), u = Us(t, e), h = o.get(u); if (h) { - Zs(r, e, h); + $s(r, e, h); return; } var p = i ? i(a, u, e + "", r, t, o) : void 0, v = p === void 0; if (v) { - var f = Oe(u), m = !f && jn(u), y = !f && !m && mi(u); - p = u, f || m || y ? Oe(a) ? p = a : df(a) ? p = Na(a) : m ? (v = !1, p = Jl(u, !0)) : y ? (v = !1, p = to(u, !0)) : p = [] : cu(u) || zs(u) ? (p = a, zs(a) ? p = gf(a) : (!Ae(a) || fi(a)) && (p = eo(u))) : v = !1; + var f = xe(u), m = !f && Cn(u), y = !f && !m && ti(u); + p = u, f || m || y ? xe(a) ? p = a : Kh(a) ? p = na(a) : m ? (v = !1, p = Pl(u, !0)) : y ? (v = !1, p = $l(u, !0)) : p = [] : Fc(u) || Bs(u) ? (p = a, Bs(a) ? p = Gh(a) : (!pe(a) || Ys(a)) && (p = Ul(u))) : v = !1; } - v && (o.set(u, p), s(p, u, n, i, o), o.delete(u)), Zs(r, e, p); + v && (o.set(u, p), s(p, u, n, i, o), o.delete(u)), $s(r, e, p); } -function oo(r, t, e, n, s) { - r !== t && ff(t, function(i, o) { - if (s || (s = new Xt()), Ae(i)) - pf(r, t, o, e, oo, n, s); +function Vl(r, t, e, n, s) { + r !== t && zh(t, function(i, o) { + if (s || (s = new Vt()), pe(i)) + Vh(r, t, o, e, Vl, n, s); else { - var a = n ? n(Ys(r, o), i, o + "", r, t, s) : void 0; - a === void 0 && (a = i), Zs(r, o, a); + var a = n ? n(Us(r, o), i, o + "", r, t, s) : void 0; + a === void 0 && (a = i), $s(r, o, a); } - }, Zl); + }, Bl); } -function pe(r, t) { - return lo(r, t); +function ce(r, t) { + return Gl(r, t); } -var ve = Ua(function(r, t, e) { - oo(r, t, e); -}), D = /* @__PURE__ */ ((r) => (r[r.TYPE = 3] = "TYPE", r[r.LEVEL = 12] = "LEVEL", r[r.ATTRIBUTE = 13] = "ATTRIBUTE", r[r.BLOT = 14] = "BLOT", r[r.INLINE = 7] = "INLINE", r[r.BLOCK = 11] = "BLOCK", r[r.BLOCK_BLOT = 10] = "BLOCK_BLOT", r[r.INLINE_BLOT = 6] = "INLINE_BLOT", r[r.BLOCK_ATTRIBUTE = 9] = "BLOCK_ATTRIBUTE", r[r.INLINE_ATTRIBUTE = 5] = "INLINE_ATTRIBUTE", r[r.ANY = 15] = "ANY", r))(D || {}); -class Jt { +var fe = Ea(function(r, t, e) { + Vl(r, t, e); +}), M = /* @__PURE__ */ ((r) => (r[r.TYPE = 3] = "TYPE", r[r.LEVEL = 12] = "LEVEL", r[r.ATTRIBUTE = 13] = "ATTRIBUTE", r[r.BLOT = 14] = "BLOT", r[r.INLINE = 7] = "INLINE", r[r.BLOCK = 11] = "BLOCK", r[r.BLOCK_BLOT = 10] = "BLOCK_BLOT", r[r.INLINE_BLOT = 6] = "INLINE_BLOT", r[r.BLOCK_ATTRIBUTE = 9] = "BLOCK_ATTRIBUTE", r[r.INLINE_ATTRIBUTE = 5] = "INLINE_ATTRIBUTE", r[r.ANY = 15] = "ANY", r))(M || {}); +class Wt { constructor(t, e, n = {}) { this.attrName = t, this.keyName = e; - const s = D.TYPE & D.ATTRIBUTE; + const s = M.TYPE & M.ATTRIBUTE; this.scope = n.scope != null ? ( // Ignore type bits, force attribute bit - n.scope & D.LEVEL | s - ) : D.ATTRIBUTE, n.whitelist != null && (this.whitelist = n.whitelist); + n.scope & M.LEVEL | s + ) : M.ATTRIBUTE, n.whitelist != null && (this.whitelist = n.whitelist); } static keys(t) { return Array.from(t.attributes).map((e) => e.name); @@ -907,12 +895,12 @@ class Jt { return this.canAdd(t, e) && e ? e : ""; } } -class rn extends Error { +class Qe extends Error { constructor(t) { t = "[Parchment] " + t, super(t), this.message = t, this.name = this.constructor.name; } } -const ao = class Xs { +const Wl = class Fs { constructor() { this.attributes = {}, this.classes = {}, this.tags = {}, this.types = {}; } @@ -935,27 +923,27 @@ const ao = class Xs { create(t, e, n) { const s = this.query(e); if (s == null) - throw new rn(`Unable to create ${e} blot`); + throw new Qe(`Unable to create ${e} blot`); const i = s, o = ( // @ts-expect-error Fix me later e instanceof Node || e.nodeType === Node.TEXT_NODE ? e : i.create(n) ), a = new i(t, o, n); - return Xs.blots.set(a.domNode, a), a; + return Fs.blots.set(a.domNode, a), a; } find(t, e = !1) { - return Xs.find(t, e); + return Fs.find(t, e); } - query(t, e = D.ANY) { + query(t, e = M.ANY) { let n; - return typeof t == "string" ? n = this.types[t] || this.attributes[t] : t instanceof Text || t.nodeType === Node.TEXT_NODE ? n = this.types.text : typeof t == "number" ? t & D.LEVEL & D.BLOCK ? n = this.types.block : t & D.LEVEL & D.INLINE && (n = this.types.inline) : t instanceof Element && ((t.getAttribute("class") || "").split(/\s+/).some((s) => (n = this.classes[s], !!n)), n = n || this.tags[t.tagName]), n == null ? null : "scope" in n && e & D.LEVEL & n.scope && e & D.TYPE & n.scope ? n : null; + return typeof t == "string" ? n = this.types[t] || this.attributes[t] : t instanceof Text || t.nodeType === Node.TEXT_NODE ? n = this.types.text : typeof t == "number" ? t & M.LEVEL & M.BLOCK ? n = this.types.block : t & M.LEVEL & M.INLINE && (n = this.types.inline) : t instanceof Element && ((t.getAttribute("class") || "").split(/\s+/).some((s) => (n = this.classes[s], !!n)), n = n || this.tags[t.tagName]), n == null ? null : "scope" in n && e & M.LEVEL & n.scope && e & M.TYPE & n.scope ? n : null; } register(...t) { return t.map((e) => { const n = "blotName" in e, s = "attrName" in e; if (!n && !s) - throw new rn("Invalid definition"); + throw new Qe("Invalid definition"); if (n && e.blotName === "abstract") - throw new rn("Cannot register abstract class"); + throw new Qe("Cannot register abstract class"); const i = n ? e.blotName : s ? e.attrName : void 0; return this.types[i] = e, s ? typeof e.keyName == "string" && (this.attributes[e.keyName] = e) : n && (e.className && (this.classes[e.className] = e), e.tagName && (Array.isArray(e.tagName) ? e.tagName = e.tagName.map((o) => o.toUpperCase()) : e.tagName = e.tagName.toUpperCase(), (Array.isArray(e.tagName) ? e.tagName : [e.tagName]).forEach((o) => { (this.tags[o] == null || e.className == null) && (this.tags[o] = e); @@ -963,12 +951,12 @@ const ao = class Xs { }); } }; -ao.blots = /* @__PURE__ */ new WeakMap(); -let on = ao; -function sl(r, t) { +Wl.blots = /* @__PURE__ */ new WeakMap(); +let en = Wl; +function Hi(r, t) { return (r.getAttribute("class") || "").split(/\s+/).filter((e) => e.indexOf(`${t}-`) === 0); } -class mf extends Jt { +class Wh extends Wt { static keys(t) { return (t.getAttribute("class") || "").split(/\s+/).map((e) => e.split("-").slice(0, -1).join("-")); } @@ -976,37 +964,37 @@ class mf extends Jt { return this.canAdd(t, e) ? (this.remove(t), t.classList.add(`${this.keyName}-${e}`), !0) : !1; } remove(t) { - sl(t, this.keyName).forEach((e) => { + Hi(t, this.keyName).forEach((e) => { t.classList.remove(e); }), t.classList.length === 0 && t.removeAttribute("class"); } value(t) { - const e = (sl(t, this.keyName)[0] || "").slice(this.keyName.length + 1); + const e = (Hi(t, this.keyName)[0] || "").slice(this.keyName.length + 1); return this.canAdd(t, e) ? e : ""; } } -const Ft = mf; -function Is(r) { +const Ut = Wh; +function Ns(r) { const t = r.split("-"), e = t.slice(1).map((n) => n[0].toUpperCase() + n.slice(1)).join(""); return t[0] + e; } -class bf extends Jt { +class Zh extends Wt { static keys(t) { return (t.getAttribute("style") || "").split(";").map((e) => e.split(":")[0].trim()); } add(t, e) { - return this.canAdd(t, e) ? (t.style[Is(this.keyName)] = e, !0) : !1; + return this.canAdd(t, e) ? (t.style[Ns(this.keyName)] = e, !0) : !1; } remove(t) { - t.style[Is(this.keyName)] = "", t.getAttribute("style") || t.removeAttribute("style"); + t.style[Ns(this.keyName)] = "", t.getAttribute("style") || t.removeAttribute("style"); } value(t) { - const e = t.style[Is(this.keyName)]; + const e = t.style[Ns(this.keyName)]; return this.canAdd(t, e) ? e : ""; } } -const we = bf; -class yf { +const me = Zh; +class Yh { constructor(t) { this.attributes = {}, this.domNode = t, this.build(); } @@ -1015,13 +1003,13 @@ class yf { } build() { this.attributes = {}; - const t = on.find(this.domNode); + const t = en.find(this.domNode); if (t == null) return; - const e = Jt.keys(this.domNode), n = Ft.keys(this.domNode), s = we.keys(this.domNode); + const e = Wt.keys(this.domNode), n = Ut.keys(this.domNode), s = me.keys(this.domNode); e.concat(n).concat(s).forEach((i) => { - const o = t.scroll.query(i, D.ATTRIBUTE); - o instanceof Jt && (this.attributes[o.attrName] = o); + const o = t.scroll.query(i, M.ATTRIBUTE); + o instanceof Wt && (this.attributes[o.attrName] = o); }); } copy(t) { @@ -1042,13 +1030,13 @@ class yf { ); } } -const Br = yf, co = class { +const Lr = Yh, Zl = class { constructor(t, e) { - this.scroll = t, this.domNode = e, on.blots.set(e, this), this.prev = null, this.next = null; + this.scroll = t, this.domNode = e, en.blots.set(e, this), this.prev = null, this.next = null; } static create(t) { if (this.tagName == null) - throw new rn("Blot definition missing tagName"); + throw new Qe("Blot definition missing tagName"); let e, n; return Array.isArray(this.tagName) ? (typeof t == "string" ? (n = t.toUpperCase(), parseInt(n, 10).toString() === n && (n = parseInt(n, 10))) : typeof t == "number" && (n = t), typeof n == "number" ? e = document.createElement(this.tagName[n - 1]) : n && this.tagName.indexOf(n) > -1 ? e = document.createElement(n) : e = document.createElement(this.tagName[0])) : e = document.createElement(this.tagName), this.className && e.classList.add(this.className), e; } @@ -1063,16 +1051,16 @@ const Br = yf, co = class { return this.scroll.create(t); } detach() { - this.parent != null && this.parent.removeChild(this), on.blots.delete(this.domNode); + this.parent != null && this.parent.removeChild(this), en.blots.delete(this.domNode); } deleteAt(t, e) { this.isolate(t, e).remove(); } formatAt(t, e, n, s) { const i = this.isolate(t, e); - if (this.scroll.query(n, D.BLOT) != null && s) + if (this.scroll.query(n, M.BLOT) != null && s) i.wrap(n, s); - else if (this.scroll.query(n, D.ATTRIBUTE) != null) { + else if (this.scroll.query(n, M.ATTRIBUTE) != null) { const o = this.scroll.create(this.statics.scope); i.wrap(o), o.format(n, s); } @@ -1111,13 +1099,13 @@ const Br = yf, co = class { wrap(t, e) { const n = typeof t == "string" ? this.scroll.create(t, e) : t; if (this.parent != null && this.parent.insertBefore(n, this.next || void 0), typeof n.appendChild != "function") - throw new rn(`Cannot wrap ${t}`); + throw new Qe(`Cannot wrap ${t}`); return n.appendChild(this), n; } }; -co.blotName = "abstract"; -let uo = co; -const ho = class extends uo { +Zl.blotName = "abstract"; +let Yl = Zl; +const Xl = class extends Yl { /** * Returns the value represented by domNode if it is this Blot's type * No checking that domNode can represent this Blot type is required so @@ -1152,10 +1140,10 @@ const ho = class extends uo { }; } }; -ho.scope = D.INLINE_BLOT; -let vf = ho; -const Et = vf; -class Ef { +Xl.scope = M.INLINE_BLOT; +let Xh = Xl; +const vt = Xh; +class Qh { constructor() { this.head = null, this.tail = null, this.length = 0; } @@ -1257,20 +1245,20 @@ class Ef { return e; } } -function il(r, t) { +function zi(r, t) { const e = t.find(r); if (e) return e; try { return t.create(r); } catch { - const n = t.create(D.INLINE); + const n = t.create(M.INLINE); return Array.from(r.childNodes).forEach((s) => { n.domNode.appendChild(s); }), r.parentNode && r.parentNode.replaceChild(n.domNode, r), n.attach(), n; } } -const fo = class ge extends uo { +const Ql = class ae extends Yl { constructor(t, e) { super(t, e), this.uiNode = null, this.build(); } @@ -1283,18 +1271,18 @@ const fo = class ge extends uo { }); } attachUI(t) { - this.uiNode != null && this.uiNode.remove(), this.uiNode = t, ge.uiClass && this.uiNode.classList.add(ge.uiClass), this.uiNode.setAttribute("contenteditable", "false"), this.domNode.insertBefore(this.uiNode, this.domNode.firstChild); + this.uiNode != null && this.uiNode.remove(), this.uiNode = t, ae.uiClass && this.uiNode.classList.add(ae.uiClass), this.uiNode.setAttribute("contenteditable", "false"), this.domNode.insertBefore(this.uiNode, this.domNode.firstChild); } /** * Called during construction, should fill its own children LinkedList. */ build() { - this.children = new Ef(), Array.from(this.domNode.childNodes).filter((t) => t !== this.uiNode).reverse().forEach((t) => { + this.children = new Qh(), Array.from(this.domNode.childNodes).filter((t) => t !== this.uiNode).reverse().forEach((t) => { try { - const e = il(t, this.scroll); + const e = zi(t, this.scroll); this.insertBefore(e, this.children.head || void 0); } catch (e) { - if (e instanceof rn) + if (e instanceof Qe) return; throw e; } @@ -1309,7 +1297,7 @@ const fo = class ge extends uo { } descendant(t, e = 0) { const [n, s] = this.children.find(e); - return t.blotName == null && t(n) || t.blotName != null && n instanceof t ? [n, s] : n instanceof ge ? n.descendant(t, s) : [null, -1]; + return t.blotName == null && t(n) || t.blotName != null && n instanceof t ? [n, s] : n instanceof ae ? n.descendant(t, s) : [null, -1]; } descendants(t, e = 0, n = Number.MAX_VALUE) { let s = [], i = n; @@ -1317,7 +1305,7 @@ const fo = class ge extends uo { e, n, (o, a, u) => { - (t.blotName == null && t(o) || t.blotName != null && o instanceof t) && s.push(o), o instanceof ge && (s = s.concat( + (t.blotName == null && t(o) || t.blotName != null && o instanceof t) && s.push(o), o instanceof ae && (s = s.concat( o.descendants(t, a, i) )), i -= u; } @@ -1333,7 +1321,7 @@ const fo = class ge extends uo { this.children.forEach((e) => { t || this.statics.allowedChildren.some( (n) => e instanceof n - ) || (e.statics.scope === D.BLOCK_BLOT ? (e.next != null && this.splitAfter(e), e.prev != null && this.splitAfter(e.prev), e.parent.unwrap(), t = !0) : e instanceof ge ? e.unwrap() : e.remove()); + ) || (e.statics.scope === M.BLOCK_BLOT ? (e.next != null && this.splitAfter(e), e.prev != null && this.splitAfter(e.prev), e.parent.unwrap(), t = !0) : e instanceof ae ? e.unwrap() : e.remove()); }); } formatAt(t, e, n, s) { @@ -1373,14 +1361,14 @@ const fo = class ge extends uo { } path(t, e = !1) { const [n, s] = this.children.find(t, e), i = [[this, t]]; - return n instanceof ge ? i.concat(n.path(s, e)) : (n != null && i.push([n, s]), i); + return n instanceof ae ? i.concat(n.path(s, e)) : (n != null && i.push([n, s]), i); } removeChild(t) { this.children.remove(t); } replaceWith(t, e) { const n = typeof t == "string" ? this.scroll.create(t, e) : t; - return n instanceof ge && this.moveChildren(n), super.replaceWith(n); + return n instanceof ae && this.moveChildren(n), super.replaceWith(n); } split(t, e = !1) { if (!e) { @@ -1417,15 +1405,15 @@ const fo = class ge extends uo { }), n.filter((i) => i.parentNode === this.domNode && i !== this.uiNode).sort((i, o) => i === o ? 0 : i.compareDocumentPosition(o) & Node.DOCUMENT_POSITION_FOLLOWING ? 1 : -1).forEach((i) => { let o = null; i.nextSibling != null && (o = this.scroll.find(i.nextSibling)); - const a = il(i, this.scroll); + const a = zi(i, this.scroll); (a.next !== o || a.next == null) && (a.parent != null && a.parent.removeChild(this), this.insertBefore(a, o || void 0)); }), this.enforceAllowedChildren(); } }; -fo.uiClass = ""; -let Af = fo; -const Pt = Af; -function wf(r, t) { +Ql.uiClass = ""; +let Jh = Ql; +const Pt = Jh; +function tf(r, t) { if (Object.keys(r).length !== Object.keys(t).length) return !1; for (const e in r) @@ -1433,12 +1421,12 @@ function wf(r, t) { return !1; return !0; } -const Ye = class Xe extends Pt { +const Ge = class Ve extends Pt { static create(t) { return super.create(t); } static formats(t, e) { - const n = e.query(Xe.blotName); + const n = e.query(Ve.blotName); if (!(n != null && t.tagName === n.tagName)) { if (typeof this.tagName == "string") return !0; @@ -1447,18 +1435,18 @@ const Ye = class Xe extends Pt { } } constructor(t, e) { - super(t, e), this.attributes = new Br(this.domNode); + super(t, e), this.attributes = new Lr(this.domNode); } format(t, e) { if (t === this.statics.blotName && !e) this.children.forEach((n) => { - n instanceof Xe || (n = n.wrap(Xe.blotName, !0)), this.attributes.copy(n); + n instanceof Ve || (n = n.wrap(Ve.blotName, !0)), this.attributes.copy(n); }), this.unwrap(); else { - const n = this.scroll.query(t, D.INLINE); + const n = this.scroll.query(t, M.INLINE); if (n == null) return; - n instanceof Jt ? this.attributes.attribute(n, e) : e && (t !== this.statics.blotName || this.formats()[t] !== e) && this.replaceWith(t, e); + n instanceof Wt ? this.attributes.attribute(n, e) : e && (t !== this.statics.blotName || this.formats()[t] !== e) && this.replaceWith(t, e); } } formats() { @@ -1466,7 +1454,7 @@ const Ye = class Xe extends Pt { return e != null && (t[this.statics.blotName] = e), t; } formatAt(t, e, n, s) { - this.formats()[n] != null || this.scroll.query(n, D.ATTRIBUTE) ? this.isolate(t, e).format(n, s) : super.formatAt(t, e, n, s); + this.formats()[n] != null || this.scroll.query(n, M.ATTRIBUTE) ? this.isolate(t, e).format(n, s) : super.formatAt(t, e, n, s); } optimize(t) { super.optimize(t); @@ -1474,7 +1462,7 @@ const Ye = class Xe extends Pt { if (Object.keys(e).length === 0) return this.unwrap(); const n = this.next; - n instanceof Xe && n.prev === this && wf(e, n.formats()) && (n.moveChildren(this), n.remove()); + n instanceof Ve && n.prev === this && tf(e, n.formats()) && (n.moveChildren(this), n.remove()); } replaceWith(t, e) { const n = super.replaceWith(t, e); @@ -1487,17 +1475,17 @@ const Ye = class Xe extends Pt { } wrap(t, e) { const n = super.wrap(t, e); - return n instanceof Xe && this.attributes.move(n), n; + return n instanceof Ve && this.attributes.move(n), n; } }; -Ye.allowedChildren = [Ye, Et], Ye.blotName = "inline", Ye.scope = D.INLINE_BLOT, Ye.tagName = "SPAN"; -let Nf = Ye; -const yi = Nf, Qe = class Qs extends Pt { +Ge.allowedChildren = [Ge, vt], Ge.blotName = "inline", Ge.scope = M.INLINE_BLOT, Ge.tagName = "SPAN"; +let ef = Ge; +const ni = ef, We = class Hs extends Pt { static create(t) { return super.create(t); } static formats(t, e) { - const n = e.query(Qs.blotName); + const n = e.query(Hs.blotName); if (!(n != null && t.tagName === n.tagName)) { if (typeof this.tagName == "string") return !0; @@ -1506,21 +1494,21 @@ const yi = Nf, Qe = class Qs extends Pt { } } constructor(t, e) { - super(t, e), this.attributes = new Br(this.domNode); + super(t, e), this.attributes = new Lr(this.domNode); } format(t, e) { - const n = this.scroll.query(t, D.BLOCK); - n != null && (n instanceof Jt ? this.attributes.attribute(n, e) : t === this.statics.blotName && !e ? this.replaceWith(Qs.blotName) : e && (t !== this.statics.blotName || this.formats()[t] !== e) && this.replaceWith(t, e)); + const n = this.scroll.query(t, M.BLOCK); + n != null && (n instanceof Wt ? this.attributes.attribute(n, e) : t === this.statics.blotName && !e ? this.replaceWith(Hs.blotName) : e && (t !== this.statics.blotName || this.formats()[t] !== e) && this.replaceWith(t, e)); } formats() { const t = this.attributes.values(), e = this.statics.formats(this.domNode, this.scroll); return e != null && (t[this.statics.blotName] = e), t; } formatAt(t, e, n, s) { - this.scroll.query(n, D.BLOCK) != null ? this.format(n, s) : super.formatAt(t, e, n, s); + this.scroll.query(n, M.BLOCK) != null ? this.format(n, s) : super.formatAt(t, e, n, s); } insertAt(t, e, n) { - if (n == null || this.scroll.query(e, D.INLINE) != null) + if (n == null || this.scroll.query(e, M.INLINE) != null) super.insertAt(t, e, n); else { const s = this.split(t); @@ -1541,13 +1529,13 @@ const yi = Nf, Qe = class Qs extends Pt { ) && this.attributes.build(); } }; -Qe.blotName = "block", Qe.scope = D.BLOCK_BLOT, Qe.tagName = "P", Qe.allowedChildren = [ - yi, - Qe, - Et +We.blotName = "block", We.scope = M.BLOCK_BLOT, We.tagName = "P", We.allowedChildren = [ + ni, + We, + vt ]; -let Tf = Qe; -const Un = Tf, Js = class extends Pt { +let nf = We; +const Rn = nf, zs = class extends Pt { checkMerge() { return this.next !== null && this.next.statics.blotName === this.statics.blotName; } @@ -1564,10 +1552,10 @@ const Un = Tf, Js = class extends Pt { super.optimize(t), this.children.length > 0 && this.next != null && this.checkMerge() && (this.next.moveChildren(this), this.next.remove()); } }; -Js.blotName = "container", Js.scope = D.BLOCK_BLOT; -let xf = Js; -const Mr = xf; -class Lf extends Et { +zs.blotName = "container", zs.scope = M.BLOCK_BLOT; +let rf = zs; +const Sr = rf; +class sf extends vt { static formats(t, e) { } format(t, e) { @@ -1580,17 +1568,17 @@ class Lf extends Et { return this.statics.formats(this.domNode, this.scroll); } } -const Lt = Lf, Sf = { +const Lt = sf, lf = { attributes: !0, characterData: !0, characterDataOldValue: !0, childList: !0, subtree: !0 -}, _f = 100, Je = class extends Pt { +}, of = 100, Ze = class extends Pt { constructor(t, e) { super(null, e), this.registry = t, this.scroll = this, this.build(), this.observer = new MutationObserver((n) => { this.update(n); - }), this.observer.observe(this.domNode, Sf), this.attach(); + }), this.observer.observe(this.domNode, lf), this.attach(); } create(t, e) { return this.registry.create(this, t, e); @@ -1599,7 +1587,7 @@ const Lt = Lf, Sf = { const n = this.registry.find(t, e); return n ? n.scroll === this ? n : e ? this.find(n.scroll.domNode.parentNode, !0) : null : null; } - query(t, e = D.ANY) { + query(t, e = M.ANY) { return this.registry.query(t, e); } register(...t) { @@ -1635,7 +1623,7 @@ const Lt = Lf, Sf = { }; let a = t; for (let u = 0; a.length > 0; u += 1) { - if (u >= _f) + if (u >= of) throw new Error("[Parchment] Maximum optimize iterations reached"); for (a.forEach((h) => { const p = this.find(h.target, !0); @@ -1660,9 +1648,9 @@ const Lt = Lf, Sf = { }), e.mutationsMap = n, n.has(this.domNode) && super.update(n.get(this.domNode), e), this.optimize(t, e); } }; -Je.blotName = "scroll", Je.defaultChild = Un, Je.allowedChildren = [Un, Mr], Je.scope = D.BLOCK_BLOT, Je.tagName = "DIV"; -let qf = Je; -const vi = qf, ti = class go extends Et { +Ze.blotName = "scroll", Ze.defaultChild = Rn, Ze.allowedChildren = [Rn, Sr], Ze.scope = M.BLOCK_BLOT, Ze.tagName = "DIV"; +let af = Ze; +const ri = af, Ks = class Jl extends vt { static create(t) { return document.createTextNode(t); } @@ -1685,7 +1673,7 @@ const vi = qf, ti = class go extends Et { return this.text.length; } optimize(t) { - super.optimize(t), this.text = this.statics.value(this.domNode), this.text.length === 0 ? this.remove() : this.next instanceof go && this.next.prev === this && (this.insertAt(this.length(), this.next.value()), this.next.remove()); + super.optimize(t), this.text = this.statics.value(this.domNode), this.text.length === 0 ? this.remove() : this.next instanceof Jl && this.next.prev === this && (this.insertAt(this.length(), this.next.value()), this.next.remove()); } position(t, e = !1) { return [this.domNode, t]; @@ -1707,31 +1695,31 @@ const vi = qf, ti = class go extends Et { return this.text; } }; -ti.blotName = "text", ti.scope = D.INLINE_BLOT; -let Of = ti; -const Cr = Of, Cf = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +Ks.blotName = "text", Ks.scope = M.INLINE_BLOT; +let cf = Ks; +const Er = cf, uf = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, - Attributor: Jt, - AttributorStore: Br, - BlockBlot: Un, - ClassAttributor: Ft, - ContainerBlot: Mr, + Attributor: Wt, + AttributorStore: Lr, + BlockBlot: Rn, + ClassAttributor: Ut, + ContainerBlot: Sr, EmbedBlot: Lt, - InlineBlot: yi, - LeafBlot: Et, + InlineBlot: ni, + LeafBlot: vt, ParentBlot: Pt, - Registry: on, - Scope: D, - ScrollBlot: vi, - StyleAttributor: we, - TextBlot: Cr + Registry: en, + Scope: M, + ScrollBlot: ri, + StyleAttributor: me, + TextBlot: Er }, Symbol.toStringTag, { value: "Module" })); -var mr = { exports: {} }, Rs, ll; -function If() { - if (ll) return Rs; - ll = 1; +var lr = { exports: {} }, Ts, Ki; +function hf() { + if (Ki) return Ts; + Ki = 1; var r = -1, t = 1, e = 0; - function n(g, L, b, q, S) { + function n(g, L, b, C, S) { if (g === L) return g ? [[e, g]] : []; if (b != null) { @@ -1739,12 +1727,12 @@ function If() { if (T) return T; } - var _ = a(g, L), k = g.substring(0, _); + var _ = a(g, L), I = g.substring(0, _); g = g.substring(_), L = L.substring(_), _ = h(g, L); - var j = g.substring(g.length - _); + var D = g.substring(g.length - _); g = g.substring(0, g.length - _), L = L.substring(0, L.length - _); - var I = s(g, L); - return k && I.unshift([e, k]), j && I.push([e, j]), C(I, S), q && v(I), I; + var O = s(g, L); + return I && O.unshift([e, I]), D && O.push([e, D]), q(O, S), C && v(O), O; } function s(g, L) { var b; @@ -1752,12 +1740,12 @@ function If() { return [[t, L]]; if (!L) return [[r, g]]; - var q = g.length > L.length ? g : L, S = g.length > L.length ? L : g, T = q.indexOf(S); + var C = g.length > L.length ? g : L, S = g.length > L.length ? L : g, T = C.indexOf(S); if (T !== -1) return b = [ - [t, q.substring(0, T)], + [t, C.substring(0, T)], [e, S], - [t, q.substring(T + S.length)] + [t, C.substring(T + S.length)] ], g.length > L.length && (b[0][0] = b[2][0] = r), b; if (S.length === 1) return [ @@ -1766,49 +1754,49 @@ function If() { ]; var _ = p(g, L); if (_) { - var k = _[0], j = _[1], I = _[2], K = _[3], F = _[4], V = n(k, I), W = n(j, K); - return V.concat([[e, F]], W); + var I = _[0], D = _[1], O = _[2], z = _[3], U = _[4], G = n(I, O), V = n(D, z); + return G.concat([[e, U]], V); } return i(g, L); } function i(g, L) { - for (var b = g.length, q = L.length, S = Math.ceil((b + q) / 2), T = S, _ = 2 * S, k = new Array(_), j = new Array(_), I = 0; I < _; I++) - k[I] = -1, j[I] = -1; - k[T + 1] = 0, j[T + 1] = 0; - for (var K = b - q, F = K % 2 !== 0, V = 0, W = 0, P = 0, it = 0, ot = 0; ot < S; ot++) { - for (var G = -ot + V; G <= ot - W; G += 2) { - var Q = T + G, X; - G === -ot || G !== ot && k[Q - 1] < k[Q + 1] ? X = k[Q + 1] : X = k[Q - 1] + 1; - for (var J = X - G; X < b && J < q && g.charAt(X) === L.charAt(J); ) - X++, J++; - if (k[Q] = X, X > b) - W += 2; - else if (J > q) + for (var b = g.length, C = L.length, S = Math.ceil((b + C) / 2), T = S, _ = 2 * S, I = new Array(_), D = new Array(_), O = 0; O < _; O++) + I[O] = -1, D[O] = -1; + I[T + 1] = 0, D[T + 1] = 0; + for (var z = b - C, U = z % 2 !== 0, G = 0, V = 0, P = 0, st = 0, lt = 0; lt < S; lt++) { + for (var K = -lt + G; K <= lt - V; K += 2) { + var X = T + K, Y; + K === -lt || K !== lt && I[X - 1] < I[X + 1] ? Y = I[X + 1] : Y = I[X - 1] + 1; + for (var Q = Y - K; Y < b && Q < C && g.charAt(Y) === L.charAt(Q); ) + Y++, Q++; + if (I[X] = Y, Y > b) V += 2; - else if (F) { - var et = T + K - G; - if (et >= 0 && et < _ && j[et] !== -1) { - var nt = b - j[et]; - if (X >= nt) - return o(g, L, X, J); + else if (Q > C) + G += 2; + else if (U) { + var tt = T + z - K; + if (tt >= 0 && tt < _ && D[tt] !== -1) { + var et = b - D[tt]; + if (Y >= et) + return o(g, L, Y, Q); } } } - for (var ct = -ot + P; ct <= ot - it; ct += 2) { - var et = T + ct, nt; - ct === -ot || ct !== ot && j[et - 1] < j[et + 1] ? nt = j[et + 1] : nt = j[et - 1] + 1; - for (var mt = nt - ct; nt < b && mt < q && g.charAt(b - nt - 1) === L.charAt(q - mt - 1); ) - nt++, mt++; - if (j[et] = nt, nt > b) - it += 2; - else if (mt > q) + for (var at = -lt + P; at <= lt - st; at += 2) { + var tt = T + at, et; + at === -lt || at !== lt && D[tt - 1] < D[tt + 1] ? et = D[tt + 1] : et = D[tt - 1] + 1; + for (var pt = et - at; et < b && pt < C && g.charAt(b - et - 1) === L.charAt(C - pt - 1); ) + et++, pt++; + if (D[tt] = et, et > b) + st += 2; + else if (pt > C) P += 2; - else if (!F) { - var Q = T + K - ct; - if (Q >= 0 && Q < _ && k[Q] !== -1) { - var X = k[Q], J = T + X - Q; - if (nt = b - nt, X >= nt) - return o(g, L, X, J); + else if (!U) { + var X = T + z - at; + if (X >= 0 && X < _ && I[X] !== -1) { + var Y = I[X], Q = T + Y - X; + if (et = b - et, Y >= et) + return o(g, L, Y, Q); } } } @@ -1818,133 +1806,133 @@ function If() { [t, L] ]; } - function o(g, L, b, q) { - var S = g.substring(0, b), T = L.substring(0, q), _ = g.substring(b), k = L.substring(q), j = n(S, T), I = n(_, k); - return j.concat(I); + function o(g, L, b, C) { + var S = g.substring(0, b), T = L.substring(0, C), _ = g.substring(b), I = L.substring(C), D = n(S, T), O = n(_, I); + return D.concat(O); } function a(g, L) { if (!g || !L || g.charAt(0) !== L.charAt(0)) return 0; - for (var b = 0, q = Math.min(g.length, L.length), S = q, T = 0; b < S; ) - g.substring(T, S) == L.substring(T, S) ? (b = S, T = b) : q = S, S = Math.floor((q - b) / 2 + b); - return M(g.charCodeAt(S - 1)) && S--, S; + for (var b = 0, C = Math.min(g.length, L.length), S = C, T = 0; b < S; ) + g.substring(T, S) == L.substring(T, S) ? (b = S, T = b) : C = S, S = Math.floor((C - b) / 2 + b); + return B(g.charCodeAt(S - 1)) && S--, S; } function u(g, L) { - var b = g.length, q = L.length; - if (b == 0 || q == 0) + var b = g.length, C = L.length; + if (b == 0 || C == 0) return 0; - b > q ? g = g.substring(b - q) : b < q && (L = L.substring(0, b)); - var S = Math.min(b, q); + b > C ? g = g.substring(b - C) : b < C && (L = L.substring(0, b)); + var S = Math.min(b, C); if (g == L) return S; for (var T = 0, _ = 1; ; ) { - var k = g.substring(S - _), j = L.indexOf(k); - if (j == -1) + var I = g.substring(S - _), D = L.indexOf(I); + if (D == -1) return T; - _ += j, (j == 0 || g.substring(S - _) == L.substring(0, _)) && (T = _, _++); + _ += D, (D == 0 || g.substring(S - _) == L.substring(0, _)) && (T = _, _++); } } function h(g, L) { if (!g || !L || g.slice(-1) !== L.slice(-1)) return 0; - for (var b = 0, q = Math.min(g.length, L.length), S = q, T = 0; b < S; ) - g.substring(g.length - S, g.length - T) == L.substring(L.length - S, L.length - T) ? (b = S, T = b) : q = S, S = Math.floor((q - b) / 2 + b); - return z(g.charCodeAt(g.length - S)) && S--, S; + for (var b = 0, C = Math.min(g.length, L.length), S = C, T = 0; b < S; ) + g.substring(g.length - S, g.length - T) == L.substring(L.length - S, L.length - T) ? (b = S, T = b) : C = S, S = Math.floor((C - b) / 2 + b); + return H(g.charCodeAt(g.length - S)) && S--, S; } function p(g, L) { - var b = g.length > L.length ? g : L, q = g.length > L.length ? L : g; - if (b.length < 4 || q.length * 2 < b.length) + var b = g.length > L.length ? g : L, C = g.length > L.length ? L : g; + if (b.length < 4 || C.length * 2 < b.length) return null; - function S(W, P, it) { - for (var ot = W.substring(it, it + Math.floor(W.length / 4)), G = -1, Q = "", X, J, et, nt; (G = P.indexOf(ot, G + 1)) !== -1; ) { - var ct = a( - W.substring(it), - P.substring(G) - ), mt = h( - W.substring(0, it), - P.substring(0, G) + function S(V, P, st) { + for (var lt = V.substring(st, st + Math.floor(V.length / 4)), K = -1, X = "", Y, Q, tt, et; (K = P.indexOf(lt, K + 1)) !== -1; ) { + var at = a( + V.substring(st), + P.substring(K) + ), pt = h( + V.substring(0, st), + P.substring(0, K) ); - Q.length < mt + ct && (Q = P.substring(G - mt, G) + P.substring(G, G + ct), X = W.substring(0, it - mt), J = W.substring(it + ct), et = P.substring(0, G - mt), nt = P.substring(G + ct)); + X.length < pt + at && (X = P.substring(K - pt, K) + P.substring(K, K + at), Y = V.substring(0, st - pt), Q = V.substring(st + at), tt = P.substring(0, K - pt), et = P.substring(K + at)); } - return Q.length * 2 >= W.length ? [ - X, - J, + return X.length * 2 >= V.length ? [ + Y, + Q, + tt, et, - nt, - Q + X ] : null; } var T = S( b, - q, + C, Math.ceil(b.length / 4) ), _ = S( b, - q, + C, Math.ceil(b.length / 2) - ), k; + ), I; if (!T && !_) return null; - _ ? T ? k = T[4].length > _[4].length ? T : _ : k = _ : k = T; - var j, I, K, F; - g.length > L.length ? (j = k[0], I = k[1], K = k[2], F = k[3]) : (K = k[0], F = k[1], j = k[2], I = k[3]); - var V = k[4]; - return [j, I, K, F, V]; + _ ? T ? I = T[4].length > _[4].length ? T : _ : I = _ : I = T; + var D, O, z, U; + g.length > L.length ? (D = I[0], O = I[1], z = I[2], U = I[3]) : (z = I[0], U = I[1], D = I[2], O = I[3]); + var G = I[4]; + return [D, O, z, U, G]; } function v(g) { - for (var L = !1, b = [], q = 0, S = null, T = 0, _ = 0, k = 0, j = 0, I = 0; T < g.length; ) - g[T][0] == e ? (b[q++] = T, _ = j, k = I, j = 0, I = 0, S = g[T][1]) : (g[T][0] == t ? j += g[T][1].length : I += g[T][1].length, S && S.length <= Math.max(_, k) && S.length <= Math.max(j, I) && (g.splice(b[q - 1], 0, [ + for (var L = !1, b = [], C = 0, S = null, T = 0, _ = 0, I = 0, D = 0, O = 0; T < g.length; ) + g[T][0] == e ? (b[C++] = T, _ = D, I = O, D = 0, O = 0, S = g[T][1]) : (g[T][0] == t ? D += g[T][1].length : O += g[T][1].length, S && S.length <= Math.max(_, I) && S.length <= Math.max(D, O) && (g.splice(b[C - 1], 0, [ r, S - ]), g[b[q - 1] + 1][0] = t, q--, q--, T = q > 0 ? b[q - 1] : -1, _ = 0, k = 0, j = 0, I = 0, S = null, L = !0)), T++; - for (L && C(g), x(g), T = 1; T < g.length; ) { + ]), g[b[C - 1] + 1][0] = t, C--, C--, T = C > 0 ? b[C - 1] : -1, _ = 0, I = 0, D = 0, O = 0, S = null, L = !0)), T++; + for (L && q(g), x(g), T = 1; T < g.length; ) { if (g[T - 1][0] == r && g[T][0] == t) { - var K = g[T - 1][1], F = g[T][1], V = u(K, F), W = u(F, K); - V >= W ? (V >= K.length / 2 || V >= F.length / 2) && (g.splice(T, 0, [ + var z = g[T - 1][1], U = g[T][1], G = u(z, U), V = u(U, z); + G >= V ? (G >= z.length / 2 || G >= U.length / 2) && (g.splice(T, 0, [ e, - F.substring(0, V) - ]), g[T - 1][1] = K.substring( + U.substring(0, G) + ]), g[T - 1][1] = z.substring( 0, - K.length - V - ), g[T + 1][1] = F.substring(V), T++) : (W >= K.length / 2 || W >= F.length / 2) && (g.splice(T, 0, [ + z.length - G + ), g[T + 1][1] = U.substring(G), T++) : (V >= z.length / 2 || V >= U.length / 2) && (g.splice(T, 0, [ e, - K.substring(0, W) - ]), g[T - 1][0] = t, g[T - 1][1] = F.substring( + z.substring(0, V) + ]), g[T - 1][0] = t, g[T - 1][1] = U.substring( 0, - F.length - W - ), g[T + 1][0] = r, g[T + 1][1] = K.substring(W), T++), T++; + U.length - V + ), g[T + 1][0] = r, g[T + 1][1] = z.substring(V), T++), T++; } T++; } } - var f = /[^a-zA-Z0-9]/, m = /\s/, y = /[\r\n]/, A = /\n\r?\n$/, N = /^\r?\n\r?\n/; + var f = /[^a-zA-Z0-9]/, m = /\s/, y = /[\r\n]/, w = /\n\r?\n$/, N = /^\r?\n\r?\n/; function x(g) { - function L(W, P) { - if (!W || !P) + function L(V, P) { + if (!V || !P) return 6; - var it = W.charAt(W.length - 1), ot = P.charAt(0), G = it.match(f), Q = ot.match(f), X = G && it.match(m), J = Q && ot.match(m), et = X && it.match(y), nt = J && ot.match(y), ct = et && W.match(A), mt = nt && P.match(N); - return ct || mt ? 5 : et || nt ? 4 : G && !X && J ? 3 : X || J ? 2 : G || Q ? 1 : 0; + var st = V.charAt(V.length - 1), lt = P.charAt(0), K = st.match(f), X = lt.match(f), Y = K && st.match(m), Q = X && lt.match(m), tt = Y && st.match(y), et = Q && lt.match(y), at = tt && V.match(w), pt = et && P.match(N); + return at || pt ? 5 : tt || et ? 4 : K && !Y && Q ? 3 : Y || Q ? 2 : K || X ? 1 : 0; } for (var b = 1; b < g.length - 1; ) { if (g[b - 1][0] == e && g[b + 1][0] == e) { - var q = g[b - 1][1], S = g[b][1], T = g[b + 1][1], _ = h(q, S); + var C = g[b - 1][1], S = g[b][1], T = g[b + 1][1], _ = h(C, S); if (_) { - var k = S.substring(S.length - _); - q = q.substring(0, q.length - _), S = k + S.substring(0, S.length - _), T = k + T; + var I = S.substring(S.length - _); + C = C.substring(0, C.length - _), S = I + S.substring(0, S.length - _), T = I + T; } - for (var j = q, I = S, K = T, F = L(q, S) + L(S, T); S.charAt(0) === T.charAt(0); ) { - q += S.charAt(0), S = S.substring(1) + T.charAt(0), T = T.substring(1); - var V = L(q, S) + L(S, T); - V >= F && (F = V, j = q, I = S, K = T); + for (var D = C, O = S, z = T, U = L(C, S) + L(S, T); S.charAt(0) === T.charAt(0); ) { + C += S.charAt(0), S = S.substring(1) + T.charAt(0), T = T.substring(1); + var G = L(C, S) + L(S, T); + G >= U && (U = G, D = C, O = S, z = T); } - g[b - 1][1] != j && (j ? g[b - 1][1] = j : (g.splice(b - 1, 1), b--), g[b][1] = I, K ? g[b + 1][1] = K : (g.splice(b + 1, 1), b--)); + g[b - 1][1] != D && (D ? g[b - 1][1] = D : (g.splice(b - 1, 1), b--), g[b][1] = O, z ? g[b + 1][1] = z : (g.splice(b + 1, 1), b--)); } b++; } } - function C(g, L) { + function q(g, L) { g.push([e, ""]); - for (var b = 0, q = 0, S = 0, T = "", _ = "", k; b < g.length; ) { + for (var b = 0, C = 0, S = 0, T = "", _ = "", I; b < g.length; ) { if (b < g.length - 1 && !g[b][1]) { g.splice(b, 1); continue; @@ -1954,25 +1942,25 @@ function If() { S++, _ += g[b][1], b++; break; case r: - q++, T += g[b][1], b++; + C++, T += g[b][1], b++; break; case e: - var j = b - S - q - 1; + var D = b - S - C - 1; if (L) { - if (j >= 0 && rt(g[j][1])) { - var I = g[j][1].slice(-1); - if (g[j][1] = g[j][1].slice( + if (D >= 0 && nt(g[D][1])) { + var O = g[D][1].slice(-1); + if (g[D][1] = g[D][1].slice( 0, -1 - ), T = I + T, _ = I + _, !g[j][1]) { - g.splice(j, 1), b--; - var K = j - 1; - g[K] && g[K][0] === t && (S++, _ = g[K][1] + _, K--), g[K] && g[K][0] === r && (q++, T = g[K][1] + T, K--), j = K; + ), T = O + T, _ = O + _, !g[D][1]) { + g.splice(D, 1), b--; + var z = D - 1; + g[z] && g[z][0] === t && (S++, _ = g[z][1] + _, z--), g[z] && g[z][0] === r && (C++, T = g[z][1] + T, z--), D = z; } } - if (U(g[b][1])) { - var I = g[b][1].charAt(0); - g[b][1] = g[b][1].slice(1), T += I, _ += I; + if ($(g[b][1])) { + var O = g[b][1].charAt(0); + g[b][1] = g[b][1].slice(1), T += O, _ += O; } } if (b < g.length - 1 && !g[b][1]) { @@ -1980,153 +1968,153 @@ function If() { break; } if (T.length > 0 || _.length > 0) { - T.length > 0 && _.length > 0 && (k = a(_, T), k !== 0 && (j >= 0 ? g[j][1] += _.substring( + T.length > 0 && _.length > 0 && (I = a(_, T), I !== 0 && (D >= 0 ? g[D][1] += _.substring( 0, - k + I ) : (g.splice(0, 0, [ e, - _.substring(0, k) - ]), b++), _ = _.substring(k), T = T.substring(k)), k = h(_, T), k !== 0 && (g[b][1] = _.substring(_.length - k) + g[b][1], _ = _.substring( + _.substring(0, I) + ]), b++), _ = _.substring(I), T = T.substring(I)), I = h(_, T), I !== 0 && (g[b][1] = _.substring(_.length - I) + g[b][1], _ = _.substring( 0, - _.length - k + _.length - I ), T = T.substring( 0, - T.length - k + T.length - I ))); - var F = S + q; - T.length === 0 && _.length === 0 ? (g.splice(b - F, F), b = b - F) : T.length === 0 ? (g.splice(b - F, F, [t, _]), b = b - F + 1) : _.length === 0 ? (g.splice(b - F, F, [r, T]), b = b - F + 1) : (g.splice( - b - F, - F, + var U = S + C; + T.length === 0 && _.length === 0 ? (g.splice(b - U, U), b = b - U) : T.length === 0 ? (g.splice(b - U, U, [t, _]), b = b - U + 1) : _.length === 0 ? (g.splice(b - U, U, [r, T]), b = b - U + 1) : (g.splice( + b - U, + U, [r, T], [t, _] - ), b = b - F + 2); + ), b = b - U + 2); } - b !== 0 && g[b - 1][0] === e ? (g[b - 1][1] += g[b][1], g.splice(b, 1)) : b++, S = 0, q = 0, T = "", _ = ""; + b !== 0 && g[b - 1][0] === e ? (g[b - 1][1] += g[b][1], g.splice(b, 1)) : b++, S = 0, C = 0, T = "", _ = ""; break; } } g[g.length - 1][1] === "" && g.pop(); - var V = !1; + var G = !1; for (b = 1; b < g.length - 1; ) g[b - 1][0] === e && g[b + 1][0] === e && (g[b][1].substring( g[b][1].length - g[b - 1][1].length ) === g[b - 1][1] ? (g[b][1] = g[b - 1][1] + g[b][1].substring( 0, g[b][1].length - g[b - 1][1].length - ), g[b + 1][1] = g[b - 1][1] + g[b + 1][1], g.splice(b - 1, 1), V = !0) : g[b][1].substring(0, g[b + 1][1].length) == g[b + 1][1] && (g[b - 1][1] += g[b + 1][1], g[b][1] = g[b][1].substring(g[b + 1][1].length) + g[b + 1][1], g.splice(b + 1, 1), V = !0)), b++; - V && C(g, L); + ), g[b + 1][1] = g[b - 1][1] + g[b + 1][1], g.splice(b - 1, 1), G = !0) : g[b][1].substring(0, g[b + 1][1].length) == g[b + 1][1] && (g[b - 1][1] += g[b + 1][1], g[b][1] = g[b][1].substring(g[b + 1][1].length) + g[b + 1][1], g.splice(b + 1, 1), G = !0)), b++; + G && q(g, L); } - function M(g) { + function B(g) { return g >= 55296 && g <= 56319; } - function z(g) { + function H(g) { return g >= 56320 && g <= 57343; } - function U(g) { - return z(g.charCodeAt(0)); + function $(g) { + return H(g.charCodeAt(0)); } - function rt(g) { - return M(g.charCodeAt(g.length - 1)); + function nt(g) { + return B(g.charCodeAt(g.length - 1)); } - function at(g) { + function ot(g) { for (var L = [], b = 0; b < g.length; b++) g[b][1].length > 0 && L.push(g[b]); return L; } - function ft(g, L, b, q) { - return rt(g) || U(q) ? null : at([ + function ht(g, L, b, C) { + return nt(g) || $(C) ? null : ot([ [e, g], [r, L], [t, b], - [e, q] + [e, C] ]); } function Rt(g, L, b) { - var q = typeof b == "number" ? { index: b, length: 0 } : b.oldRange, S = typeof b == "number" ? null : b.newRange, T = g.length, _ = L.length; - if (q.length === 0 && (S === null || S.length === 0)) { - var k = q.index, j = g.slice(0, k), I = g.slice(k), K = S ? S.index : null; + var C = typeof b == "number" ? { index: b, length: 0 } : b.oldRange, S = typeof b == "number" ? null : b.newRange, T = g.length, _ = L.length; + if (C.length === 0 && (S === null || S.length === 0)) { + var I = C.index, D = g.slice(0, I), O = g.slice(I), z = S ? S.index : null; t: { - var F = k + _ - T; - if (K !== null && K !== F || F < 0 || F > _) + var U = I + _ - T; + if (z !== null && z !== U || U < 0 || U > _) break t; - var V = L.slice(0, F), W = L.slice(F); - if (W !== I) + var G = L.slice(0, U), V = L.slice(U); + if (V !== O) break t; - var P = Math.min(k, F), it = j.slice(0, P), ot = V.slice(0, P); - if (it !== ot) + var P = Math.min(I, U), st = D.slice(0, P), lt = G.slice(0, P); + if (st !== lt) break t; - var G = j.slice(P), Q = V.slice(P); - return ft(it, G, Q, I); + var K = D.slice(P), X = G.slice(P); + return ht(st, K, X, O); } t: { - if (K !== null && K !== k) + if (z !== null && z !== I) break t; - var X = k, V = L.slice(0, X), W = L.slice(X); - if (V !== j) + var Y = I, G = L.slice(0, Y), V = L.slice(Y); + if (G !== D) break t; - var J = Math.min(T - X, _ - X), et = I.slice(I.length - J), nt = W.slice(W.length - J); - if (et !== nt) + var Q = Math.min(T - Y, _ - Y), tt = O.slice(O.length - Q), et = V.slice(V.length - Q); + if (tt !== et) break t; - var G = I.slice(0, I.length - J), Q = W.slice(0, W.length - J); - return ft(j, G, Q, et); + var K = O.slice(0, O.length - Q), X = V.slice(0, V.length - Q); + return ht(D, K, X, tt); } } - if (q.length > 0 && S && S.length === 0) + if (C.length > 0 && S && S.length === 0) t: { - var it = g.slice(0, q.index), et = g.slice(q.index + q.length), P = it.length, J = et.length; - if (_ < P + J) + var st = g.slice(0, C.index), tt = g.slice(C.index + C.length), P = st.length, Q = tt.length; + if (_ < P + Q) break t; - var ot = L.slice(0, P), nt = L.slice(_ - J); - if (it !== ot || et !== nt) + var lt = L.slice(0, P), et = L.slice(_ - Q); + if (st !== lt || tt !== et) break t; - var G = g.slice(P, T - J), Q = L.slice(P, _ - J); - return ft(it, G, Q, et); + var K = g.slice(P, T - Q), X = L.slice(P, _ - Q); + return ht(st, K, X, tt); } return null; } - function xt(g, L, b, q) { - return n(g, L, b, q, !0); + function xt(g, L, b, C) { + return n(g, L, b, C, !0); } - return xt.INSERT = t, xt.DELETE = r, xt.EQUAL = e, Rs = xt, Rs; + return xt.INSERT = t, xt.DELETE = r, xt.EQUAL = e, Ts = xt, Ts; } -var Cn = { exports: {} }; -Cn.exports; -var ol; -function po() { - return ol || (ol = 1, function(r, t) { - var e = 200, n = "__lodash_hash_undefined__", s = 9007199254740991, i = "[object Arguments]", o = "[object Array]", a = "[object Boolean]", u = "[object Date]", h = "[object Error]", p = "[object Function]", v = "[object GeneratorFunction]", f = "[object Map]", m = "[object Number]", y = "[object Object]", A = "[object Promise]", N = "[object RegExp]", x = "[object Set]", C = "[object String]", M = "[object Symbol]", z = "[object WeakMap]", U = "[object ArrayBuffer]", rt = "[object DataView]", at = "[object Float32Array]", ft = "[object Float64Array]", Rt = "[object Int8Array]", xt = "[object Int16Array]", g = "[object Int32Array]", L = "[object Uint8Array]", b = "[object Uint8ClampedArray]", q = "[object Uint16Array]", S = "[object Uint32Array]", T = /[\\^$.*+?()[\]{}|]/g, _ = /\w*$/, k = /^\[object .+?Constructor\]$/, j = /^(?:0|[1-9]\d*)$/, I = {}; - I[i] = I[o] = I[U] = I[rt] = I[a] = I[u] = I[at] = I[ft] = I[Rt] = I[xt] = I[g] = I[f] = I[m] = I[y] = I[N] = I[x] = I[C] = I[M] = I[L] = I[b] = I[q] = I[S] = !0, I[h] = I[p] = I[z] = !1; - var K = typeof me == "object" && me && me.Object === Object && me, F = typeof self == "object" && self && self.Object === Object && self, V = K || F || Function("return this")(), W = t && !t.nodeType && t, P = W && !0 && r && !r.nodeType && r, it = P && P.exports === W; - function ot(l, c) { +var Tn = { exports: {} }; +Tn.exports; +var Gi; +function to() { + return Gi || (Gi = 1, (function(r, t) { + var e = 200, n = "__lodash_hash_undefined__", s = 9007199254740991, i = "[object Arguments]", o = "[object Array]", a = "[object Boolean]", u = "[object Date]", h = "[object Error]", p = "[object Function]", v = "[object GeneratorFunction]", f = "[object Map]", m = "[object Number]", y = "[object Object]", w = "[object Promise]", N = "[object RegExp]", x = "[object Set]", q = "[object String]", B = "[object Symbol]", H = "[object WeakMap]", $ = "[object ArrayBuffer]", nt = "[object DataView]", ot = "[object Float32Array]", ht = "[object Float64Array]", Rt = "[object Int8Array]", xt = "[object Int16Array]", g = "[object Int32Array]", L = "[object Uint8Array]", b = "[object Uint8ClampedArray]", C = "[object Uint16Array]", S = "[object Uint32Array]", T = /[\\^$.*+?()[\]{}|]/g, _ = /\w*$/, I = /^\[object .+?Constructor\]$/, D = /^(?:0|[1-9]\d*)$/, O = {}; + O[i] = O[o] = O[$] = O[nt] = O[a] = O[u] = O[ot] = O[ht] = O[Rt] = O[xt] = O[g] = O[f] = O[m] = O[y] = O[N] = O[x] = O[q] = O[B] = O[L] = O[b] = O[C] = O[S] = !0, O[h] = O[p] = O[H] = !1; + var z = typeof ue == "object" && ue && ue.Object === Object && ue, U = typeof self == "object" && self && self.Object === Object && self, G = z || U || Function("return this")(), V = t && !t.nodeType && t, P = V && !0 && r && !r.nodeType && r, st = P && P.exports === V; + function lt(l, c) { return l.set(c[0], c[1]), l; } - function G(l, c) { + function K(l, c) { return l.add(c), l; } - function Q(l, c) { + function X(l, c) { for (var d = -1, E = l ? l.length : 0; ++d < E && c(l[d], d, l) !== !1; ) ; return l; } - function X(l, c) { - for (var d = -1, E = c.length, H = l.length; ++d < E; ) - l[H + d] = c[d]; + function Y(l, c) { + for (var d = -1, E = c.length, F = l.length; ++d < E; ) + l[F + d] = c[d]; return l; } - function J(l, c, d, E) { - for (var H = -1, $ = l ? l.length : 0; ++H < $; ) - d = c(d, l[H], H, l); + function Q(l, c, d, E) { + for (var F = -1, j = l ? l.length : 0; ++F < j; ) + d = c(d, l[F], F, l); return d; } - function et(l, c) { + function tt(l, c) { for (var d = -1, E = Array(l); ++d < l; ) E[d] = c(d); return E; } - function nt(l, c) { - return l == null ? void 0 : l[c]; + function et(l, c) { + return l?.[c]; } - function ct(l) { + function at(l) { var c = !1; if (l != null && typeof l.toString != "function") try { @@ -2135,356 +2123,356 @@ function po() { } return c; } - function mt(l) { + function pt(l) { var c = -1, d = Array(l.size); - return l.forEach(function(E, H) { - d[++c] = [H, E]; + return l.forEach(function(E, F) { + d[++c] = [F, E]; }), d; } - function fn(l, c) { + function sn(l, c) { return function(d) { return l(c(d)); }; } - function Yn(l) { + function $n(l) { var c = -1, d = Array(l.size); return l.forEach(function(E) { d[++c] = E; }), d; } - var Ur = Array.prototype, Fr = Function.prototype, $e = Object.prototype, dn = V["__core-js_shared__"], Xn = function() { - var l = /[^.]+$/.exec(dn && dn.keys && dn.keys.IE_PROTO || ""); + var Rr = Array.prototype, Ir = Function.prototype, Be = Object.prototype, ln = G["__core-js_shared__"], Un = (function() { + var l = /[^.]+$/.exec(ln && ln.keys && ln.keys.IE_PROTO || ""); return l ? "Symbol(src)_1." + l : ""; - }(), Qn = Fr.toString, Kt = $e.hasOwnProperty, Pe = $e.toString, Hr = RegExp( - "^" + Qn.call(Kt).replace(T, "\\$&").replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g, "$1.*?") + "$" - ), Ne = it ? V.Buffer : void 0, Ue = V.Symbol, gn = V.Uint8Array, St = fn(Object.getPrototypeOf, Object), Jn = Object.create, tr = $e.propertyIsEnumerable, zr = Ur.splice, pn = Object.getOwnPropertySymbols, Fe = Ne ? Ne.isBuffer : void 0, er = fn(Object.keys, Object), He = Bt(V, "DataView"), Te = Bt(V, "Map"), kt = Bt(V, "Promise"), ze = Bt(V, "Set"), mn = Bt(V, "WeakMap"), xe = Bt(Object, "create"), bn = wt(He), Le = wt(Te), yn = wt(kt), vn = wt(ze), En = wt(mn), fe = Ue ? Ue.prototype : void 0, nr = fe ? fe.valueOf : void 0; - function ne(l) { + })(), Fn = Ir.toString, Ht = Be.hasOwnProperty, Me = Be.toString, kr = RegExp( + "^" + Fn.call(Ht).replace(T, "\\$&").replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g, "$1.*?") + "$" + ), be = st ? G.Buffer : void 0, De = G.Symbol, on = G.Uint8Array, St = sn(Object.getPrototypeOf, Object), Hn = Object.create, zn = Be.propertyIsEnumerable, Br = Rr.splice, an = Object.getOwnPropertySymbols, je = be ? be.isBuffer : void 0, Kn = sn(Object.keys, Object), Pe = kt(G, "DataView"), ye = kt(G, "Map"), It = kt(G, "Promise"), $e = kt(G, "Set"), cn = kt(G, "WeakMap"), ve = kt(Object, "create"), un = Et(Pe), Ee = Et(ye), hn = Et(It), fn = Et($e), dn = Et(cn), le = De ? De.prototype : void 0, Gn = le ? le.valueOf : void 0; + function Xt(l) { var c = -1, d = l ? l.length : 0; for (this.clear(); ++c < d; ) { var E = l[c]; this.set(E[0], E[1]); } } - function Kr() { - this.__data__ = xe ? xe(null) : {}; + function Mr() { + this.__data__ = ve ? ve(null) : {}; } - function Gr(l) { + function Dr(l) { return this.has(l) && delete this.__data__[l]; } - function Vr(l) { + function jr(l) { var c = this.__data__; - if (xe) { + if (ve) { var d = c[l]; return d === n ? void 0 : d; } - return Kt.call(c, l) ? c[l] : void 0; + return Ht.call(c, l) ? c[l] : void 0; } - function rr(l) { + function Vn(l) { var c = this.__data__; - return xe ? c[l] !== void 0 : Kt.call(c, l); + return ve ? c[l] !== void 0 : Ht.call(c, l); } - function An(l, c) { + function gn(l, c) { var d = this.__data__; - return d[l] = xe && c === void 0 ? n : c, this; + return d[l] = ve && c === void 0 ? n : c, this; } - ne.prototype.clear = Kr, ne.prototype.delete = Gr, ne.prototype.get = Vr, ne.prototype.has = rr, ne.prototype.set = An; - function dt(l) { + Xt.prototype.clear = Mr, Xt.prototype.delete = Dr, Xt.prototype.get = jr, Xt.prototype.has = Vn, Xt.prototype.set = gn; + function ft(l) { var c = -1, d = l ? l.length : 0; for (this.clear(); ++c < d; ) { var E = l[c]; this.set(E[0], E[1]); } } - function Wr() { + function Pr() { this.__data__ = []; } - function Zr(l) { - var c = this.__data__, d = Ge(c, l); + function $r(l) { + var c = this.__data__, d = Fe(c, l); if (d < 0) return !1; var E = c.length - 1; - return d == E ? c.pop() : zr.call(c, d, 1), !0; + return d == E ? c.pop() : Br.call(c, d, 1), !0; } - function Yr(l) { - var c = this.__data__, d = Ge(c, l); + function Ur(l) { + var c = this.__data__, d = Fe(c, l); return d < 0 ? void 0 : c[d][1]; } - function Xr(l) { - return Ge(this.__data__, l) > -1; + function Fr(l) { + return Fe(this.__data__, l) > -1; } - function Qr(l, c) { - var d = this.__data__, E = Ge(d, l); + function Hr(l, c) { + var d = this.__data__, E = Fe(d, l); return E < 0 ? d.push([l, c]) : d[E][1] = c, this; } - dt.prototype.clear = Wr, dt.prototype.delete = Zr, dt.prototype.get = Yr, dt.prototype.has = Xr, dt.prototype.set = Qr; - function bt(l) { + ft.prototype.clear = Pr, ft.prototype.delete = $r, ft.prototype.get = Ur, ft.prototype.has = Fr, ft.prototype.set = Hr; + function mt(l) { var c = -1, d = l ? l.length : 0; for (this.clear(); ++c < d; ) { var E = l[c]; this.set(E[0], E[1]); } } - function Jr() { + function zr() { this.__data__ = { - hash: new ne(), - map: new (Te || dt)(), - string: new ne() + hash: new Xt(), + map: new (ye || ft)(), + string: new Xt() }; } - function ts(l) { - return _e(this, l).delete(l); + function Kr(l) { + return we(this, l).delete(l); } - function es(l) { - return _e(this, l).get(l); + function Gr(l) { + return we(this, l).get(l); } - function ns(l) { - return _e(this, l).has(l); + function Vr(l) { + return we(this, l).has(l); } - function rs(l, c) { - return _e(this, l).set(l, c), this; + function Wr(l, c) { + return we(this, l).set(l, c), this; } - bt.prototype.clear = Jr, bt.prototype.delete = ts, bt.prototype.get = es, bt.prototype.has = ns, bt.prototype.set = rs; + mt.prototype.clear = zr, mt.prototype.delete = Kr, mt.prototype.get = Gr, mt.prototype.has = Vr, mt.prototype.set = Wr; function Nt(l) { - this.__data__ = new dt(l); + this.__data__ = new ft(l); } - function ss() { - this.__data__ = new dt(); + function Zr() { + this.__data__ = new ft(); } - function is(l) { + function Yr(l) { return this.__data__.delete(l); } - function ls(l) { + function Xr(l) { return this.__data__.get(l); } - function os(l) { + function Qr(l) { return this.__data__.has(l); } - function as(l, c) { + function Jr(l, c) { var d = this.__data__; - if (d instanceof dt) { + if (d instanceof ft) { var E = d.__data__; - if (!Te || E.length < e - 1) + if (!ye || E.length < e - 1) return E.push([l, c]), this; - d = this.__data__ = new bt(E); + d = this.__data__ = new mt(E); } return d.set(l, c), this; } - Nt.prototype.clear = ss, Nt.prototype.delete = is, Nt.prototype.get = ls, Nt.prototype.has = os, Nt.prototype.set = as; - function Ke(l, c) { - var d = xn(l) || We(l) ? et(l.length, String) : [], E = d.length, H = !!E; - for (var $ in l) - Kt.call(l, $) && !(H && ($ == "length" || ws($, E))) && d.push($); + Nt.prototype.clear = Zr, Nt.prototype.delete = Yr, Nt.prototype.get = Xr, Nt.prototype.has = Qr, Nt.prototype.set = Jr; + function Ue(l, c) { + var d = yn(l) || ze(l) ? tt(l.length, String) : [], E = d.length, F = !!E; + for (var j in l) + Ht.call(l, j) && !(F && (j == "length" || ds(j, E))) && d.push(j); return d; } - function sr(l, c, d) { + function Wn(l, c, d) { var E = l[c]; - (!(Kt.call(l, c) && cr(E, d)) || d === void 0 && !(c in l)) && (l[c] = d); + (!(Ht.call(l, c) && Jn(E, d)) || d === void 0 && !(c in l)) && (l[c] = d); } - function Ge(l, c) { + function Fe(l, c) { for (var d = l.length; d--; ) - if (cr(l[d][0], c)) + if (Jn(l[d][0], c)) return d; return -1; } - function Gt(l, c) { - return l && Tn(c, Sn(c), l); + function zt(l, c) { + return l && bn(c, En(c), l); } - function wn(l, c, d, E, H, $, Y) { - var Z; - if (E && (Z = $ ? E(l, H, $, Y) : E(l)), Z !== void 0) - return Z; - if (!Wt(l)) + function pn(l, c, d, E, F, j, Z) { + var W; + if (E && (W = j ? E(l, F, j, Z) : E(l)), W !== void 0) + return W; + if (!Gt(l)) return l; - var ut = xn(l); - if (ut) { - if (Z = Es(l), !c) - return bs(l, Z); + var ct = yn(l); + if (ct) { + if (W = hs(l), !c) + return as(l, W); } else { - var tt = se(l), yt = tt == p || tt == v; - if (ur(l)) - return Ve(l, c); - if (tt == y || tt == i || yt && !$) { - if (ct(l)) - return $ ? l : {}; - if (Z = Vt(yt ? {} : l), !c) - return ys(l, Gt(Z, l)); + var J = Jt(l), bt = J == p || J == v; + if (tr(l)) + return He(l, c); + if (J == y || J == i || bt && !j) { + if (at(l)) + return j ? l : {}; + if (W = Kt(bt ? {} : l), !c) + return cs(l, zt(W, l)); } else { - if (!I[tt]) - return $ ? l : {}; - Z = As(l, tt, wn, c); + if (!O[J]) + return j ? l : {}; + W = fs(l, J, pn, c); } } - Y || (Y = new Nt()); - var Tt = Y.get(l); + Z || (Z = new Nt()); + var Tt = Z.get(l); if (Tt) return Tt; - if (Y.set(l, Z), !ut) - var ht = d ? vs(l) : Sn(l); - return Q(ht || l, function(vt, gt) { - ht && (gt = vt, vt = l[gt]), sr(Z, gt, wn(vt, c, d, E, gt, l, Y)); - }), Z; + if (Z.set(l, W), !ct) + var ut = d ? us(l) : En(l); + return X(ut || l, function(yt, dt) { + ut && (dt = yt, yt = l[dt]), Wn(W, dt, pn(yt, c, d, E, dt, l, Z)); + }), W; } - function cs(l) { - return Wt(l) ? Jn(l) : {}; + function ts(l) { + return Gt(l) ? Hn(l) : {}; } - function us(l, c, d) { + function es(l, c, d) { var E = c(l); - return xn(l) ? E : X(E, d(l)); + return yn(l) ? E : Y(E, d(l)); } - function hs(l) { - return Pe.call(l); + function ns(l) { + return Me.call(l); } - function fs(l) { - if (!Wt(l) || Ts(l)) + function rs(l) { + if (!Gt(l) || ps(l)) return !1; - var c = Ln(l) || ct(l) ? Hr : k; - return c.test(wt(l)); + var c = vn(l) || at(l) ? kr : I; + return c.test(Et(l)); } - function ds(l) { - if (!or(l)) - return er(l); + function ss(l) { + if (!Xn(l)) + return Kn(l); var c = []; for (var d in Object(l)) - Kt.call(l, d) && d != "constructor" && c.push(d); + Ht.call(l, d) && d != "constructor" && c.push(d); return c; } - function Ve(l, c) { + function He(l, c) { if (c) return l.slice(); var d = new l.constructor(l.length); return l.copy(d), d; } - function Nn(l) { + function mn(l) { var c = new l.constructor(l.byteLength); - return new gn(c).set(new gn(l)), c; + return new on(c).set(new on(l)), c; } - function Se(l, c) { - var d = c ? Nn(l.buffer) : l.buffer; + function Ae(l, c) { + var d = c ? mn(l.buffer) : l.buffer; return new l.constructor(d, l.byteOffset, l.byteLength); } - function ir(l, c, d) { - var E = c ? d(mt(l), !0) : mt(l); - return J(E, ot, new l.constructor()); + function Zn(l, c, d) { + var E = c ? d(pt(l), !0) : pt(l); + return Q(E, lt, new l.constructor()); } - function lr(l) { + function Yn(l) { var c = new l.constructor(l.source, _.exec(l)); return c.lastIndex = l.lastIndex, c; } - function gs(l, c, d) { - var E = c ? d(Yn(l), !0) : Yn(l); - return J(E, G, new l.constructor()); + function is(l, c, d) { + var E = c ? d($n(l), !0) : $n(l); + return Q(E, K, new l.constructor()); } - function ps(l) { - return nr ? Object(nr.call(l)) : {}; + function ls(l) { + return Gn ? Object(Gn.call(l)) : {}; } - function ms(l, c) { - var d = c ? Nn(l.buffer) : l.buffer; + function os(l, c) { + var d = c ? mn(l.buffer) : l.buffer; return new l.constructor(d, l.byteOffset, l.length); } - function bs(l, c) { + function as(l, c) { var d = -1, E = l.length; for (c || (c = Array(E)); ++d < E; ) c[d] = l[d]; return c; } - function Tn(l, c, d, E) { + function bn(l, c, d, E) { d || (d = {}); - for (var H = -1, $ = c.length; ++H < $; ) { - var Y = c[H], Z = void 0; - sr(d, Y, Z === void 0 ? l[Y] : Z); + for (var F = -1, j = c.length; ++F < j; ) { + var Z = c[F], W = void 0; + Wn(d, Z, W === void 0 ? l[Z] : W); } return d; } - function ys(l, c) { - return Tn(l, re(l), c); + function cs(l, c) { + return bn(l, Qt(l), c); } - function vs(l) { - return us(l, Sn, re); + function us(l) { + return es(l, En, Qt); } - function _e(l, c) { + function we(l, c) { var d = l.__data__; - return Ns(c) ? d[typeof c == "string" ? "string" : "hash"] : d.map; + return gs(c) ? d[typeof c == "string" ? "string" : "hash"] : d.map; } - function Bt(l, c) { - var d = nt(l, c); - return fs(d) ? d : void 0; + function kt(l, c) { + var d = et(l, c); + return rs(d) ? d : void 0; } - var re = pn ? fn(pn, Object) : Ls, se = hs; - (He && se(new He(new ArrayBuffer(1))) != rt || Te && se(new Te()) != f || kt && se(kt.resolve()) != A || ze && se(new ze()) != x || mn && se(new mn()) != z) && (se = function(l) { - var c = Pe.call(l), d = c == y ? l.constructor : void 0, E = d ? wt(d) : void 0; + var Qt = an ? sn(an, Object) : bs, Jt = ns; + (Pe && Jt(new Pe(new ArrayBuffer(1))) != nt || ye && Jt(new ye()) != f || It && Jt(It.resolve()) != w || $e && Jt(new $e()) != x || cn && Jt(new cn()) != H) && (Jt = function(l) { + var c = Me.call(l), d = c == y ? l.constructor : void 0, E = d ? Et(d) : void 0; if (E) switch (E) { - case bn: - return rt; - case Le: + case un: + return nt; + case Ee: return f; - case yn: - return A; - case vn: + case hn: + return w; + case fn: return x; - case En: - return z; + case dn: + return H; } return c; }); - function Es(l) { + function hs(l) { var c = l.length, d = l.constructor(c); - return c && typeof l[0] == "string" && Kt.call(l, "index") && (d.index = l.index, d.input = l.input), d; + return c && typeof l[0] == "string" && Ht.call(l, "index") && (d.index = l.index, d.input = l.input), d; } - function Vt(l) { - return typeof l.constructor == "function" && !or(l) ? cs(St(l)) : {}; + function Kt(l) { + return typeof l.constructor == "function" && !Xn(l) ? ts(St(l)) : {}; } - function As(l, c, d, E) { - var H = l.constructor; + function fs(l, c, d, E) { + var F = l.constructor; switch (c) { - case U: - return Nn(l); + case $: + return mn(l); case a: case u: - return new H(+l); - case rt: - return Se(l, E); - case at: - case ft: + return new F(+l); + case nt: + return Ae(l, E); + case ot: + case ht: case Rt: case xt: case g: case L: case b: - case q: + case C: case S: - return ms(l, E); + return os(l, E); case f: - return ir(l, E, d); + return Zn(l, E, d); case m: - case C: - return new H(l); + case q: + return new F(l); case N: - return lr(l); + return Yn(l); case x: - return gs(l, E, d); - case M: - return ps(l); + return is(l, E, d); + case B: + return ls(l); } } - function ws(l, c) { - return c = c ?? s, !!c && (typeof l == "number" || j.test(l)) && l > -1 && l % 1 == 0 && l < c; + function ds(l, c) { + return c = c ?? s, !!c && (typeof l == "number" || D.test(l)) && l > -1 && l % 1 == 0 && l < c; } - function Ns(l) { + function gs(l) { var c = typeof l; return c == "string" || c == "number" || c == "symbol" || c == "boolean" ? l !== "__proto__" : l === null; } - function Ts(l) { - return !!Xn && Xn in l; + function ps(l) { + return !!Un && Un in l; } - function or(l) { - var c = l && l.constructor, d = typeof c == "function" && c.prototype || $e; + function Xn(l) { + var c = l && l.constructor, d = typeof c == "function" && c.prototype || Be; return l === d; } - function wt(l) { + function Et(l) { if (l != null) { try { - return Qn.call(l); + return Fn.call(l); } catch { } try { @@ -2494,179 +2482,179 @@ function po() { } return ""; } - function ar(l) { - return wn(l, !0, !0); + function Qn(l) { + return pn(l, !0, !0); } - function cr(l, c) { + function Jn(l, c) { return l === c || l !== l && c !== c; } - function We(l) { - return xs(l) && Kt.call(l, "callee") && (!tr.call(l, "callee") || Pe.call(l) == i); + function ze(l) { + return ms(l) && Ht.call(l, "callee") && (!zn.call(l, "callee") || Me.call(l) == i); } - var xn = Array.isArray; - function Ze(l) { - return l != null && hr(l.length) && !Ln(l); + var yn = Array.isArray; + function Ke(l) { + return l != null && er(l.length) && !vn(l); } - function xs(l) { - return fr(l) && Ze(l); + function ms(l) { + return nr(l) && Ke(l); } - var ur = Fe || Ss; - function Ln(l) { - var c = Wt(l) ? Pe.call(l) : ""; + var tr = je || ys; + function vn(l) { + var c = Gt(l) ? Me.call(l) : ""; return c == p || c == v; } - function hr(l) { + function er(l) { return typeof l == "number" && l > -1 && l % 1 == 0 && l <= s; } - function Wt(l) { + function Gt(l) { var c = typeof l; return !!l && (c == "object" || c == "function"); } - function fr(l) { + function nr(l) { return !!l && typeof l == "object"; } - function Sn(l) { - return Ze(l) ? Ke(l) : ds(l); + function En(l) { + return Ke(l) ? Ue(l) : ss(l); } - function Ls() { + function bs() { return []; } - function Ss() { + function ys() { return !1; } - r.exports = ar; - }(Cn, Cn.exports)), Cn.exports; -} -var In = { exports: {} }; -In.exports; -var al; -function mo() { - return al || (al = 1, function(r, t) { - var e = 200, n = "__lodash_hash_undefined__", s = 1, i = 2, o = 9007199254740991, a = "[object Arguments]", u = "[object Array]", h = "[object AsyncFunction]", p = "[object Boolean]", v = "[object Date]", f = "[object Error]", m = "[object Function]", y = "[object GeneratorFunction]", A = "[object Map]", N = "[object Number]", x = "[object Null]", C = "[object Object]", M = "[object Promise]", z = "[object Proxy]", U = "[object RegExp]", rt = "[object Set]", at = "[object String]", ft = "[object Symbol]", Rt = "[object Undefined]", xt = "[object WeakMap]", g = "[object ArrayBuffer]", L = "[object DataView]", b = "[object Float32Array]", q = "[object Float64Array]", S = "[object Int8Array]", T = "[object Int16Array]", _ = "[object Int32Array]", k = "[object Uint8Array]", j = "[object Uint8ClampedArray]", I = "[object Uint16Array]", K = "[object Uint32Array]", F = /[\\^$.*+?()[\]{}|]/g, V = /^\[object .+?Constructor\]$/, W = /^(?:0|[1-9]\d*)$/, P = {}; - P[b] = P[q] = P[S] = P[T] = P[_] = P[k] = P[j] = P[I] = P[K] = !0, P[a] = P[u] = P[g] = P[p] = P[L] = P[v] = P[f] = P[m] = P[A] = P[N] = P[C] = P[U] = P[rt] = P[at] = P[xt] = !1; - var it = typeof me == "object" && me && me.Object === Object && me, ot = typeof self == "object" && self && self.Object === Object && self, G = it || ot || Function("return this")(), Q = t && !t.nodeType && t, X = Q && !0 && r && !r.nodeType && r, J = X && X.exports === Q, et = J && it.process, nt = function() { + r.exports = Qn; + })(Tn, Tn.exports)), Tn.exports; +} +var xn = { exports: {} }; +xn.exports; +var Vi; +function eo() { + return Vi || (Vi = 1, (function(r, t) { + var e = 200, n = "__lodash_hash_undefined__", s = 1, i = 2, o = 9007199254740991, a = "[object Arguments]", u = "[object Array]", h = "[object AsyncFunction]", p = "[object Boolean]", v = "[object Date]", f = "[object Error]", m = "[object Function]", y = "[object GeneratorFunction]", w = "[object Map]", N = "[object Number]", x = "[object Null]", q = "[object Object]", B = "[object Promise]", H = "[object Proxy]", $ = "[object RegExp]", nt = "[object Set]", ot = "[object String]", ht = "[object Symbol]", Rt = "[object Undefined]", xt = "[object WeakMap]", g = "[object ArrayBuffer]", L = "[object DataView]", b = "[object Float32Array]", C = "[object Float64Array]", S = "[object Int8Array]", T = "[object Int16Array]", _ = "[object Int32Array]", I = "[object Uint8Array]", D = "[object Uint8ClampedArray]", O = "[object Uint16Array]", z = "[object Uint32Array]", U = /[\\^$.*+?()[\]{}|]/g, G = /^\[object .+?Constructor\]$/, V = /^(?:0|[1-9]\d*)$/, P = {}; + P[b] = P[C] = P[S] = P[T] = P[_] = P[I] = P[D] = P[O] = P[z] = !0, P[a] = P[u] = P[g] = P[p] = P[L] = P[v] = P[f] = P[m] = P[w] = P[N] = P[q] = P[$] = P[nt] = P[ot] = P[xt] = !1; + var st = typeof ue == "object" && ue && ue.Object === Object && ue, lt = typeof self == "object" && self && self.Object === Object && self, K = st || lt || Function("return this")(), X = t && !t.nodeType && t, Y = X && !0 && r && !r.nodeType && r, Q = Y && Y.exports === X, tt = Q && st.process, et = (function() { try { - return et && et.binding && et.binding("util"); + return tt && tt.binding && tt.binding("util"); } catch { } - }(), ct = nt && nt.isTypedArray; - function mt(l, c) { - for (var d = -1, E = l == null ? 0 : l.length, H = 0, $ = []; ++d < E; ) { - var Y = l[d]; - c(Y, d, l) && ($[H++] = Y); + })(), at = et && et.isTypedArray; + function pt(l, c) { + for (var d = -1, E = l == null ? 0 : l.length, F = 0, j = []; ++d < E; ) { + var Z = l[d]; + c(Z, d, l) && (j[F++] = Z); } - return $; + return j; } - function fn(l, c) { - for (var d = -1, E = c.length, H = l.length; ++d < E; ) - l[H + d] = c[d]; + function sn(l, c) { + for (var d = -1, E = c.length, F = l.length; ++d < E; ) + l[F + d] = c[d]; return l; } - function Yn(l, c) { + function $n(l, c) { for (var d = -1, E = l == null ? 0 : l.length; ++d < E; ) if (c(l[d], d, l)) return !0; return !1; } - function Ur(l, c) { + function Rr(l, c) { for (var d = -1, E = Array(l); ++d < l; ) E[d] = c(d); return E; } - function Fr(l) { + function Ir(l) { return function(c) { return l(c); }; } - function $e(l, c) { + function Be(l, c) { return l.has(c); } - function dn(l, c) { - return l == null ? void 0 : l[c]; + function ln(l, c) { + return l?.[c]; } - function Xn(l) { + function Un(l) { var c = -1, d = Array(l.size); - return l.forEach(function(E, H) { - d[++c] = [H, E]; + return l.forEach(function(E, F) { + d[++c] = [F, E]; }), d; } - function Qn(l, c) { + function Fn(l, c) { return function(d) { return l(c(d)); }; } - function Kt(l) { + function Ht(l) { var c = -1, d = Array(l.size); return l.forEach(function(E) { d[++c] = E; }), d; } - var Pe = Array.prototype, Hr = Function.prototype, Ne = Object.prototype, Ue = G["__core-js_shared__"], gn = Hr.toString, St = Ne.hasOwnProperty, Jn = function() { - var l = /[^.]+$/.exec(Ue && Ue.keys && Ue.keys.IE_PROTO || ""); + var Me = Array.prototype, kr = Function.prototype, be = Object.prototype, De = K["__core-js_shared__"], on = kr.toString, St = be.hasOwnProperty, Hn = (function() { + var l = /[^.]+$/.exec(De && De.keys && De.keys.IE_PROTO || ""); return l ? "Symbol(src)_1." + l : ""; - }(), tr = Ne.toString, zr = RegExp( - "^" + gn.call(St).replace(F, "\\$&").replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g, "$1.*?") + "$" - ), pn = J ? G.Buffer : void 0, Fe = G.Symbol, er = G.Uint8Array, He = Ne.propertyIsEnumerable, Te = Pe.splice, kt = Fe ? Fe.toStringTag : void 0, ze = Object.getOwnPropertySymbols, mn = pn ? pn.isBuffer : void 0, xe = Qn(Object.keys, Object), bn = re(G, "DataView"), Le = re(G, "Map"), yn = re(G, "Promise"), vn = re(G, "Set"), En = re(G, "WeakMap"), fe = re(Object, "create"), nr = wt(bn), ne = wt(Le), Kr = wt(yn), Gr = wt(vn), Vr = wt(En), rr = Fe ? Fe.prototype : void 0, An = rr ? rr.valueOf : void 0; - function dt(l) { + })(), zn = be.toString, Br = RegExp( + "^" + on.call(St).replace(U, "\\$&").replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g, "$1.*?") + "$" + ), an = Q ? K.Buffer : void 0, je = K.Symbol, Kn = K.Uint8Array, Pe = be.propertyIsEnumerable, ye = Me.splice, It = je ? je.toStringTag : void 0, $e = Object.getOwnPropertySymbols, cn = an ? an.isBuffer : void 0, ve = Fn(Object.keys, Object), un = Qt(K, "DataView"), Ee = Qt(K, "Map"), hn = Qt(K, "Promise"), fn = Qt(K, "Set"), dn = Qt(K, "WeakMap"), le = Qt(Object, "create"), Gn = Et(un), Xt = Et(Ee), Mr = Et(hn), Dr = Et(fn), jr = Et(dn), Vn = je ? je.prototype : void 0, gn = Vn ? Vn.valueOf : void 0; + function ft(l) { var c = -1, d = l == null ? 0 : l.length; for (this.clear(); ++c < d; ) { var E = l[c]; this.set(E[0], E[1]); } } - function Wr() { - this.__data__ = fe ? fe(null) : {}, this.size = 0; + function Pr() { + this.__data__ = le ? le(null) : {}, this.size = 0; } - function Zr(l) { + function $r(l) { var c = this.has(l) && delete this.__data__[l]; return this.size -= c ? 1 : 0, c; } - function Yr(l) { + function Ur(l) { var c = this.__data__; - if (fe) { + if (le) { var d = c[l]; return d === n ? void 0 : d; } return St.call(c, l) ? c[l] : void 0; } - function Xr(l) { + function Fr(l) { var c = this.__data__; - return fe ? c[l] !== void 0 : St.call(c, l); + return le ? c[l] !== void 0 : St.call(c, l); } - function Qr(l, c) { + function Hr(l, c) { var d = this.__data__; - return this.size += this.has(l) ? 0 : 1, d[l] = fe && c === void 0 ? n : c, this; + return this.size += this.has(l) ? 0 : 1, d[l] = le && c === void 0 ? n : c, this; } - dt.prototype.clear = Wr, dt.prototype.delete = Zr, dt.prototype.get = Yr, dt.prototype.has = Xr, dt.prototype.set = Qr; - function bt(l) { + ft.prototype.clear = Pr, ft.prototype.delete = $r, ft.prototype.get = Ur, ft.prototype.has = Fr, ft.prototype.set = Hr; + function mt(l) { var c = -1, d = l == null ? 0 : l.length; for (this.clear(); ++c < d; ) { var E = l[c]; this.set(E[0], E[1]); } } - function Jr() { + function zr() { this.__data__ = [], this.size = 0; } - function ts(l) { - var c = this.__data__, d = Ve(c, l); + function Kr(l) { + var c = this.__data__, d = He(c, l); if (d < 0) return !1; var E = c.length - 1; - return d == E ? c.pop() : Te.call(c, d, 1), --this.size, !0; + return d == E ? c.pop() : ye.call(c, d, 1), --this.size, !0; } - function es(l) { - var c = this.__data__, d = Ve(c, l); + function Gr(l) { + var c = this.__data__, d = He(c, l); return d < 0 ? void 0 : c[d][1]; } - function ns(l) { - return Ve(this.__data__, l) > -1; + function Vr(l) { + return He(this.__data__, l) > -1; } - function rs(l, c) { - var d = this.__data__, E = Ve(d, l); + function Wr(l, c) { + var d = this.__data__, E = He(d, l); return E < 0 ? (++this.size, d.push([l, c])) : d[E][1] = c, this; } - bt.prototype.clear = Jr, bt.prototype.delete = ts, bt.prototype.get = es, bt.prototype.has = ns, bt.prototype.set = rs; + mt.prototype.clear = zr, mt.prototype.delete = Kr, mt.prototype.get = Gr, mt.prototype.has = Vr, mt.prototype.set = Wr; function Nt(l) { var c = -1, d = l == null ? 0 : l.length; for (this.clear(); ++c < d; ) { @@ -2674,297 +2662,297 @@ function mo() { this.set(E[0], E[1]); } } - function ss() { + function Zr() { this.size = 0, this.__data__ = { - hash: new dt(), - map: new (Le || bt)(), - string: new dt() + hash: new ft(), + map: new (Ee || mt)(), + string: new ft() }; } - function is(l) { - var c = Bt(this, l).delete(l); + function Yr(l) { + var c = kt(this, l).delete(l); return this.size -= c ? 1 : 0, c; } - function ls(l) { - return Bt(this, l).get(l); + function Xr(l) { + return kt(this, l).get(l); } - function os(l) { - return Bt(this, l).has(l); + function Qr(l) { + return kt(this, l).has(l); } - function as(l, c) { - var d = Bt(this, l), E = d.size; + function Jr(l, c) { + var d = kt(this, l), E = d.size; return d.set(l, c), this.size += d.size == E ? 0 : 1, this; } - Nt.prototype.clear = ss, Nt.prototype.delete = is, Nt.prototype.get = ls, Nt.prototype.has = os, Nt.prototype.set = as; - function Ke(l) { + Nt.prototype.clear = Zr, Nt.prototype.delete = Yr, Nt.prototype.get = Xr, Nt.prototype.has = Qr, Nt.prototype.set = Jr; + function Ue(l) { var c = -1, d = l == null ? 0 : l.length; for (this.__data__ = new Nt(); ++c < d; ) this.add(l[c]); } - function sr(l) { + function Wn(l) { return this.__data__.set(l, n), this; } - function Ge(l) { + function Fe(l) { return this.__data__.has(l); } - Ke.prototype.add = Ke.prototype.push = sr, Ke.prototype.has = Ge; - function Gt(l) { - var c = this.__data__ = new bt(l); + Ue.prototype.add = Ue.prototype.push = Wn, Ue.prototype.has = Fe; + function zt(l) { + var c = this.__data__ = new mt(l); this.size = c.size; } - function wn() { - this.__data__ = new bt(), this.size = 0; + function pn() { + this.__data__ = new mt(), this.size = 0; } - function cs(l) { + function ts(l) { var c = this.__data__, d = c.delete(l); return this.size = c.size, d; } - function us(l) { + function es(l) { return this.__data__.get(l); } - function hs(l) { + function ns(l) { return this.__data__.has(l); } - function fs(l, c) { + function rs(l, c) { var d = this.__data__; - if (d instanceof bt) { + if (d instanceof mt) { var E = d.__data__; - if (!Le || E.length < e - 1) + if (!Ee || E.length < e - 1) return E.push([l, c]), this.size = ++d.size, this; d = this.__data__ = new Nt(E); } return d.set(l, c), this.size = d.size, this; } - Gt.prototype.clear = wn, Gt.prototype.delete = cs, Gt.prototype.get = us, Gt.prototype.has = hs, Gt.prototype.set = fs; - function ds(l, c) { - var d = We(l), E = !d && cr(l), H = !d && !E && Ze(l), $ = !d && !E && !H && fr(l), Y = d || E || H || $, Z = Y ? Ur(l.length, String) : [], ut = Z.length; - for (var tt in l) - St.call(l, tt) && !(Y && // Safari 9 has enumerable `arguments.length` in strict mode. - (tt == "length" || // Node.js 0.10 has enumerable non-index properties on buffers. - H && (tt == "offset" || tt == "parent") || // PhantomJS 2 has enumerable non-index properties on typed arrays. - $ && (tt == "buffer" || tt == "byteLength" || tt == "byteOffset") || // Skip index properties. - As(tt, ut))) && Z.push(tt); - return Z; - } - function Ve(l, c) { + zt.prototype.clear = pn, zt.prototype.delete = ts, zt.prototype.get = es, zt.prototype.has = ns, zt.prototype.set = rs; + function ss(l, c) { + var d = ze(l), E = !d && Jn(l), F = !d && !E && Ke(l), j = !d && !E && !F && nr(l), Z = d || E || F || j, W = Z ? Rr(l.length, String) : [], ct = W.length; + for (var J in l) + St.call(l, J) && !(Z && // Safari 9 has enumerable `arguments.length` in strict mode. + (J == "length" || // Node.js 0.10 has enumerable non-index properties on buffers. + F && (J == "offset" || J == "parent") || // PhantomJS 2 has enumerable non-index properties on typed arrays. + j && (J == "buffer" || J == "byteLength" || J == "byteOffset") || // Skip index properties. + fs(J, ct))) && W.push(J); + return W; + } + function He(l, c) { for (var d = l.length; d--; ) - if (ar(l[d][0], c)) + if (Qn(l[d][0], c)) return d; return -1; } - function Nn(l, c, d) { + function mn(l, c, d) { var E = c(l); - return We(l) ? E : fn(E, d(l)); + return ze(l) ? E : sn(E, d(l)); } - function Se(l) { - return l == null ? l === void 0 ? Rt : x : kt && kt in Object(l) ? se(l) : or(l); + function Ae(l) { + return l == null ? l === void 0 ? Rt : x : It && It in Object(l) ? Jt(l) : Xn(l); } - function ir(l) { - return Wt(l) && Se(l) == a; + function Zn(l) { + return Gt(l) && Ae(l) == a; } - function lr(l, c, d, E, H) { - return l === c ? !0 : l == null || c == null || !Wt(l) && !Wt(c) ? l !== l && c !== c : gs(l, c, d, E, lr, H); + function Yn(l, c, d, E, F) { + return l === c ? !0 : l == null || c == null || !Gt(l) && !Gt(c) ? l !== l && c !== c : is(l, c, d, E, Yn, F); } - function gs(l, c, d, E, H, $) { - var Y = We(l), Z = We(c), ut = Y ? u : Vt(l), tt = Z ? u : Vt(c); - ut = ut == a ? C : ut, tt = tt == a ? C : tt; - var yt = ut == C, Tt = tt == C, ht = ut == tt; - if (ht && Ze(l)) { - if (!Ze(c)) + function is(l, c, d, E, F, j) { + var Z = ze(l), W = ze(c), ct = Z ? u : Kt(l), J = W ? u : Kt(c); + ct = ct == a ? q : ct, J = J == a ? q : J; + var bt = ct == q, Tt = J == q, ut = ct == J; + if (ut && Ke(l)) { + if (!Ke(c)) return !1; - Y = !0, yt = !1; + Z = !0, bt = !1; } - if (ht && !yt) - return $ || ($ = new Gt()), Y || fr(l) ? Tn(l, c, d, E, H, $) : ys(l, c, ut, d, E, H, $); + if (ut && !bt) + return j || (j = new zt()), Z || nr(l) ? bn(l, c, d, E, F, j) : cs(l, c, ct, d, E, F, j); if (!(d & s)) { - var vt = yt && St.call(l, "__wrapped__"), gt = Tt && St.call(c, "__wrapped__"); - if (vt || gt) { - var de = vt ? l.value() : l, ie = gt ? c.value() : c; - return $ || ($ = new Gt()), H(de, ie, d, E, $); + var yt = bt && St.call(l, "__wrapped__"), dt = Tt && St.call(c, "__wrapped__"); + if (yt || dt) { + var oe = yt ? l.value() : l, te = dt ? c.value() : c; + return j || (j = new zt()), F(oe, te, d, E, j); } } - return ht ? ($ || ($ = new Gt()), vs(l, c, d, E, H, $)) : !1; + return ut ? (j || (j = new zt()), us(l, c, d, E, F, j)) : !1; } - function ps(l) { - if (!hr(l) || Ns(l)) + function ls(l) { + if (!er(l) || gs(l)) return !1; - var c = ur(l) ? zr : V; - return c.test(wt(l)); + var c = tr(l) ? Br : G; + return c.test(Et(l)); } - function ms(l) { - return Wt(l) && Ln(l.length) && !!P[Se(l)]; + function os(l) { + return Gt(l) && vn(l.length) && !!P[Ae(l)]; } - function bs(l) { - if (!Ts(l)) - return xe(l); + function as(l) { + if (!ps(l)) + return ve(l); var c = []; for (var d in Object(l)) St.call(l, d) && d != "constructor" && c.push(d); return c; } - function Tn(l, c, d, E, H, $) { - var Y = d & s, Z = l.length, ut = c.length; - if (Z != ut && !(Y && ut > Z)) + function bn(l, c, d, E, F, j) { + var Z = d & s, W = l.length, ct = c.length; + if (W != ct && !(Z && ct > W)) return !1; - var tt = $.get(l); - if (tt && $.get(c)) - return tt == c; - var yt = -1, Tt = !0, ht = d & i ? new Ke() : void 0; - for ($.set(l, c), $.set(c, l); ++yt < Z; ) { - var vt = l[yt], gt = c[yt]; + var J = j.get(l); + if (J && j.get(c)) + return J == c; + var bt = -1, Tt = !0, ut = d & i ? new Ue() : void 0; + for (j.set(l, c), j.set(c, l); ++bt < W; ) { + var yt = l[bt], dt = c[bt]; if (E) - var de = Y ? E(gt, vt, yt, c, l, $) : E(vt, gt, yt, l, c, $); - if (de !== void 0) { - if (de) + var oe = Z ? E(dt, yt, bt, c, l, j) : E(yt, dt, bt, l, c, j); + if (oe !== void 0) { + if (oe) continue; Tt = !1; break; } - if (ht) { - if (!Yn(c, function(ie, qe) { - if (!$e(ht, qe) && (vt === ie || H(vt, ie, d, E, $))) - return ht.push(qe); + if (ut) { + if (!$n(c, function(te, Ne) { + if (!Be(ut, Ne) && (yt === te || F(yt, te, d, E, j))) + return ut.push(Ne); })) { Tt = !1; break; } - } else if (!(vt === gt || H(vt, gt, d, E, $))) { + } else if (!(yt === dt || F(yt, dt, d, E, j))) { Tt = !1; break; } } - return $.delete(l), $.delete(c), Tt; + return j.delete(l), j.delete(c), Tt; } - function ys(l, c, d, E, H, $, Y) { + function cs(l, c, d, E, F, j, Z) { switch (d) { case L: if (l.byteLength != c.byteLength || l.byteOffset != c.byteOffset) return !1; l = l.buffer, c = c.buffer; case g: - return !(l.byteLength != c.byteLength || !$(new er(l), new er(c))); + return !(l.byteLength != c.byteLength || !j(new Kn(l), new Kn(c))); case p: case v: case N: - return ar(+l, +c); + return Qn(+l, +c); case f: return l.name == c.name && l.message == c.message; - case U: - case at: + case $: + case ot: return l == c + ""; - case A: - var Z = Xn; - case rt: - var ut = E & s; - if (Z || (Z = Kt), l.size != c.size && !ut) + case w: + var W = Un; + case nt: + var ct = E & s; + if (W || (W = Ht), l.size != c.size && !ct) return !1; - var tt = Y.get(l); - if (tt) - return tt == c; - E |= i, Y.set(l, c); - var yt = Tn(Z(l), Z(c), E, H, $, Y); - return Y.delete(l), yt; - case ft: - if (An) - return An.call(l) == An.call(c); + var J = Z.get(l); + if (J) + return J == c; + E |= i, Z.set(l, c); + var bt = bn(W(l), W(c), E, F, j, Z); + return Z.delete(l), bt; + case ht: + if (gn) + return gn.call(l) == gn.call(c); } return !1; } - function vs(l, c, d, E, H, $) { - var Y = d & s, Z = _e(l), ut = Z.length, tt = _e(c), yt = tt.length; - if (ut != yt && !Y) + function us(l, c, d, E, F, j) { + var Z = d & s, W = we(l), ct = W.length, J = we(c), bt = J.length; + if (ct != bt && !Z) return !1; - for (var Tt = ut; Tt--; ) { - var ht = Z[Tt]; - if (!(Y ? ht in c : St.call(c, ht))) + for (var Tt = ct; Tt--; ) { + var ut = W[Tt]; + if (!(Z ? ut in c : St.call(c, ut))) return !1; } - var vt = $.get(l); - if (vt && $.get(c)) - return vt == c; - var gt = !0; - $.set(l, c), $.set(c, l); - for (var de = Y; ++Tt < ut; ) { - ht = Z[Tt]; - var ie = l[ht], qe = c[ht]; + var yt = j.get(l); + if (yt && j.get(c)) + return yt == c; + var dt = !0; + j.set(l, c), j.set(c, l); + for (var oe = Z; ++Tt < ct; ) { + ut = W[Tt]; + var te = l[ut], Ne = c[ut]; if (E) - var Oi = Y ? E(qe, ie, ht, c, l, $) : E(ie, qe, ht, l, c, $); - if (!(Oi === void 0 ? ie === qe || H(ie, qe, d, E, $) : Oi)) { - gt = !1; + var pi = Z ? E(Ne, te, ut, c, l, j) : E(te, Ne, ut, l, c, j); + if (!(pi === void 0 ? te === Ne || F(te, Ne, d, E, j) : pi)) { + dt = !1; break; } - de || (de = ht == "constructor"); + oe || (oe = ut == "constructor"); } - if (gt && !de) { - var dr = l.constructor, gr = c.constructor; - dr != gr && "constructor" in l && "constructor" in c && !(typeof dr == "function" && dr instanceof dr && typeof gr == "function" && gr instanceof gr) && (gt = !1); + if (dt && !oe) { + var rr = l.constructor, sr = c.constructor; + rr != sr && "constructor" in l && "constructor" in c && !(typeof rr == "function" && rr instanceof rr && typeof sr == "function" && sr instanceof sr) && (dt = !1); } - return $.delete(l), $.delete(c), gt; + return j.delete(l), j.delete(c), dt; } - function _e(l) { - return Nn(l, Sn, Es); + function we(l) { + return mn(l, En, hs); } - function Bt(l, c) { + function kt(l, c) { var d = l.__data__; - return ws(c) ? d[typeof c == "string" ? "string" : "hash"] : d.map; + return ds(c) ? d[typeof c == "string" ? "string" : "hash"] : d.map; } - function re(l, c) { - var d = dn(l, c); - return ps(d) ? d : void 0; + function Qt(l, c) { + var d = ln(l, c); + return ls(d) ? d : void 0; } - function se(l) { - var c = St.call(l, kt), d = l[kt]; + function Jt(l) { + var c = St.call(l, It), d = l[It]; try { - l[kt] = void 0; + l[It] = void 0; var E = !0; } catch { } - var H = tr.call(l); - return E && (c ? l[kt] = d : delete l[kt]), H; + var F = zn.call(l); + return E && (c ? l[It] = d : delete l[It]), F; } - var Es = ze ? function(l) { - return l == null ? [] : (l = Object(l), mt(ze(l), function(c) { - return He.call(l, c); + var hs = $e ? function(l) { + return l == null ? [] : (l = Object(l), pt($e(l), function(c) { + return Pe.call(l, c); })); - } : Ls, Vt = Se; - (bn && Vt(new bn(new ArrayBuffer(1))) != L || Le && Vt(new Le()) != A || yn && Vt(yn.resolve()) != M || vn && Vt(new vn()) != rt || En && Vt(new En()) != xt) && (Vt = function(l) { - var c = Se(l), d = c == C ? l.constructor : void 0, E = d ? wt(d) : ""; + } : bs, Kt = Ae; + (un && Kt(new un(new ArrayBuffer(1))) != L || Ee && Kt(new Ee()) != w || hn && Kt(hn.resolve()) != B || fn && Kt(new fn()) != nt || dn && Kt(new dn()) != xt) && (Kt = function(l) { + var c = Ae(l), d = c == q ? l.constructor : void 0, E = d ? Et(d) : ""; if (E) switch (E) { - case nr: + case Gn: return L; - case ne: - return A; - case Kr: - return M; - case Gr: - return rt; - case Vr: + case Xt: + return w; + case Mr: + return B; + case Dr: + return nt; + case jr: return xt; } return c; }); - function As(l, c) { - return c = c ?? o, !!c && (typeof l == "number" || W.test(l)) && l > -1 && l % 1 == 0 && l < c; + function fs(l, c) { + return c = c ?? o, !!c && (typeof l == "number" || V.test(l)) && l > -1 && l % 1 == 0 && l < c; } - function ws(l) { + function ds(l) { var c = typeof l; return c == "string" || c == "number" || c == "symbol" || c == "boolean" ? l !== "__proto__" : l === null; } - function Ns(l) { - return !!Jn && Jn in l; + function gs(l) { + return !!Hn && Hn in l; } - function Ts(l) { - var c = l && l.constructor, d = typeof c == "function" && c.prototype || Ne; + function ps(l) { + var c = l && l.constructor, d = typeof c == "function" && c.prototype || be; return l === d; } - function or(l) { - return tr.call(l); + function Xn(l) { + return zn.call(l); } - function wt(l) { + function Et(l) { if (l != null) { try { - return gn.call(l); + return on.call(l); } catch { } try { @@ -2974,57 +2962,57 @@ function mo() { } return ""; } - function ar(l, c) { + function Qn(l, c) { return l === c || l !== l && c !== c; } - var cr = ir(/* @__PURE__ */ function() { + var Jn = Zn(/* @__PURE__ */ (function() { return arguments; - }()) ? ir : function(l) { - return Wt(l) && St.call(l, "callee") && !He.call(l, "callee"); - }, We = Array.isArray; - function xn(l) { - return l != null && Ln(l.length) && !ur(l); - } - var Ze = mn || Ss; - function xs(l, c) { - return lr(l, c); - } - function ur(l) { - if (!hr(l)) + })()) ? Zn : function(l) { + return Gt(l) && St.call(l, "callee") && !Pe.call(l, "callee"); + }, ze = Array.isArray; + function yn(l) { + return l != null && vn(l.length) && !tr(l); + } + var Ke = cn || ys; + function ms(l, c) { + return Yn(l, c); + } + function tr(l) { + if (!er(l)) return !1; - var c = Se(l); - return c == m || c == y || c == h || c == z; + var c = Ae(l); + return c == m || c == y || c == h || c == H; } - function Ln(l) { + function vn(l) { return typeof l == "number" && l > -1 && l % 1 == 0 && l <= o; } - function hr(l) { + function er(l) { var c = typeof l; return l != null && (c == "object" || c == "function"); } - function Wt(l) { + function Gt(l) { return l != null && typeof l == "object"; } - var fr = ct ? Fr(ct) : ms; - function Sn(l) { - return xn(l) ? ds(l) : bs(l); + var nr = at ? Ir(at) : os; + function En(l) { + return yn(l) ? ss(l) : as(l); } - function Ls() { + function bs() { return []; } - function Ss() { + function ys() { return !1; } - r.exports = xs; - }(In, In.exports)), In.exports; + r.exports = ms; + })(xn, xn.exports)), xn.exports; } -var br = {}, cl; -function Rf() { - if (cl) return br; - cl = 1, Object.defineProperty(br, "__esModule", { value: !0 }); - const r = po(), t = mo(); +var or = {}, Wi; +function ff() { + if (Wi) return or; + Wi = 1, Object.defineProperty(or, "__esModule", { value: !0 }); + const r = to(), t = eo(); var e; - return function(n) { + return (function(n) { function s(u = {}, h = {}, p = !1) { typeof u != "object" && (u = {}), typeof h != "object" && (h = {}); let v = r(h); @@ -3057,25 +3045,25 @@ function Rf() { return Object.keys(v).length > 0 ? v : void 0; } n.transform = a; - }(e || (e = {})), br.default = e, br; + })(e || (e = {})), or.default = e, or; } -var yr = {}, ul; -function bo() { - if (ul) return yr; - ul = 1, Object.defineProperty(yr, "__esModule", { value: !0 }); +var ar = {}, Zi; +function no() { + if (Zi) return ar; + Zi = 1, Object.defineProperty(ar, "__esModule", { value: !0 }); var r; - return function(t) { + return (function(t) { function e(n) { return typeof n.delete == "number" ? n.delete : typeof n.retain == "number" ? n.retain : typeof n.retain == "object" && n.retain !== null ? 1 : typeof n.insert == "string" ? n.insert.length : 1; } t.length = e; - }(r || (r = {})), yr.default = r, yr; + })(r || (r = {})), ar.default = r, ar; } -var vr = {}, hl; -function kf() { - if (hl) return vr; - hl = 1, Object.defineProperty(vr, "__esModule", { value: !0 }); - const r = bo(); +var cr = {}, Yi; +function df() { + if (Yi) return cr; + Yi = 1, Object.defineProperty(cr, "__esModule", { value: !0 }); + const r = no(); class t { constructor(n) { this.ops = n, this.index = 0, this.offset = 0; @@ -3118,17 +3106,17 @@ function kf() { } else return []; } } - return vr.default = t, vr; + return cr.default = t, cr; } -var fl; -function Bf() { - return fl || (fl = 1, function(r, t) { +var Xi; +function gf() { + return Xi || (Xi = 1, (function(r, t) { Object.defineProperty(t, "__esModule", { value: !0 }), t.AttributeMap = t.OpIterator = t.Op = void 0; - const e = If(), n = po(), s = mo(), i = Rf(); + const e = hf(), n = to(), s = eo(), i = ff(); t.AttributeMap = i.default; - const o = bo(); + const o = no(); t.Op = o.default; - const a = kf(); + const a = df(); t.OpIterator = a.default; const u = "\0", h = (v, f) => { if (typeof v != "object" || v === null) @@ -3200,8 +3188,8 @@ function Bf() { } partition(f) { const m = [], y = []; - return this.forEach((A) => { - (f(A) ? m : y).push(A); + return this.forEach((w) => { + (f(w) ? m : y).push(w); }), [m, y]; } reduce(f, m) { @@ -3214,48 +3202,48 @@ function Bf() { return this.reduce((f, m) => f + o.default.length(m), 0); } slice(f = 0, m = 1 / 0) { - const y = [], A = new a.default(this.ops); + const y = [], w = new a.default(this.ops); let N = 0; - for (; N < m && A.hasNext(); ) { + for (; N < m && w.hasNext(); ) { let x; - N < f ? x = A.next(f - N) : (x = A.next(m - N), y.push(x)), N += o.default.length(x); + N < f ? x = w.next(f - N) : (x = w.next(m - N), y.push(x)), N += o.default.length(x); } return new p(y); } compose(f) { - const m = new a.default(this.ops), y = new a.default(f.ops), A = [], N = y.peek(); + const m = new a.default(this.ops), y = new a.default(f.ops), w = [], N = y.peek(); if (N != null && typeof N.retain == "number" && N.attributes == null) { - let C = N.retain; - for (; m.peekType() === "insert" && m.peekLength() <= C; ) - C -= m.peekLength(), A.push(m.next()); - N.retain - C > 0 && y.next(N.retain - C); + let q = N.retain; + for (; m.peekType() === "insert" && m.peekLength() <= q; ) + q -= m.peekLength(), w.push(m.next()); + N.retain - q > 0 && y.next(N.retain - q); } - const x = new p(A); + const x = new p(w); for (; m.hasNext() || y.hasNext(); ) if (y.peekType() === "insert") x.push(y.next()); else if (m.peekType() === "delete") x.push(m.next()); else { - const C = Math.min(m.peekLength(), y.peekLength()), M = m.next(C), z = y.next(C); - if (z.retain) { - const U = {}; - if (typeof M.retain == "number") - U.retain = typeof z.retain == "number" ? C : z.retain; - else if (typeof z.retain == "number") - M.retain == null ? U.insert = M.insert : U.retain = M.retain; + const q = Math.min(m.peekLength(), y.peekLength()), B = m.next(q), H = y.next(q); + if (H.retain) { + const $ = {}; + if (typeof B.retain == "number") + $.retain = typeof H.retain == "number" ? q : H.retain; + else if (typeof H.retain == "number") + B.retain == null ? $.insert = B.insert : $.retain = B.retain; else { - const at = M.retain == null ? "insert" : "retain", [ft, Rt, xt] = h(M[at], z.retain), g = p.getHandler(ft); - U[at] = { - [ft]: g.compose(Rt, xt, at === "retain") + const ot = B.retain == null ? "insert" : "retain", [ht, Rt, xt] = h(B[ot], H.retain), g = p.getHandler(ht); + $[ot] = { + [ht]: g.compose(Rt, xt, ot === "retain") }; } - const rt = i.default.compose(M.attributes, z.attributes, typeof M.retain == "number"); - if (rt && (U.attributes = rt), x.push(U), !y.hasNext() && s(x.ops[x.ops.length - 1], U)) { - const at = new p(m.rest()); - return x.concat(at).chop(); + const nt = i.default.compose(B.attributes, H.attributes, typeof B.retain == "number"); + if (nt && ($.attributes = nt), x.push($), !y.hasNext() && s(x.ops[x.ops.length - 1], $)) { + const ot = new p(m.rest()); + return x.concat(ot).chop(); } - } else typeof z.delete == "number" && (typeof M.retain == "number" || typeof M.retain == "object" && M.retain !== null) && x.push(z); + } else typeof H.delete == "number" && (typeof B.retain == "number" || typeof B.retain == "object" && B.retain !== null) && x.push(H); } return x.chop(); } @@ -3266,69 +3254,69 @@ function Bf() { diff(f, m) { if (this.ops === f.ops) return new p(); - const y = [this, f].map((M) => M.map((z) => { - if (z.insert != null) - return typeof z.insert == "string" ? z.insert : u; - const U = M === f ? "on" : "with"; - throw new Error("diff() called " + U + " non-document"); - }).join("")), A = new p(), N = e(y[0], y[1], m, !0), x = new a.default(this.ops), C = new a.default(f.ops); - return N.forEach((M) => { - let z = M[1].length; - for (; z > 0; ) { - let U = 0; - switch (M[0]) { + const y = [this, f].map((B) => B.map((H) => { + if (H.insert != null) + return typeof H.insert == "string" ? H.insert : u; + const $ = B === f ? "on" : "with"; + throw new Error("diff() called " + $ + " non-document"); + }).join("")), w = new p(), N = e(y[0], y[1], m, !0), x = new a.default(this.ops), q = new a.default(f.ops); + return N.forEach((B) => { + let H = B[1].length; + for (; H > 0; ) { + let $ = 0; + switch (B[0]) { case e.INSERT: - U = Math.min(C.peekLength(), z), A.push(C.next(U)); + $ = Math.min(q.peekLength(), H), w.push(q.next($)); break; case e.DELETE: - U = Math.min(z, x.peekLength()), x.next(U), A.delete(U); + $ = Math.min(H, x.peekLength()), x.next($), w.delete($); break; case e.EQUAL: - U = Math.min(x.peekLength(), C.peekLength(), z); - const rt = x.next(U), at = C.next(U); - s(rt.insert, at.insert) ? A.retain(U, i.default.diff(rt.attributes, at.attributes)) : A.push(at).delete(U); + $ = Math.min(x.peekLength(), q.peekLength(), H); + const nt = x.next($), ot = q.next($); + s(nt.insert, ot.insert) ? w.retain($, i.default.diff(nt.attributes, ot.attributes)) : w.push(ot).delete($); break; } - z -= U; + H -= $; } - }), A.chop(); + }), w.chop(); } eachLine(f, m = ` `) { const y = new a.default(this.ops); - let A = new p(), N = 0; + let w = new p(), N = 0; for (; y.hasNext(); ) { if (y.peekType() !== "insert") return; - const x = y.peek(), C = o.default.length(x) - y.peekLength(), M = typeof x.insert == "string" ? x.insert.indexOf(m, C) - C : -1; - if (M < 0) - A.push(y.next()); - else if (M > 0) - A.push(y.next(M)); + const x = y.peek(), q = o.default.length(x) - y.peekLength(), B = typeof x.insert == "string" ? x.insert.indexOf(m, q) - q : -1; + if (B < 0) + w.push(y.next()); + else if (B > 0) + w.push(y.next(B)); else { - if (f(A, y.next(1).attributes || {}, N) === !1) + if (f(w, y.next(1).attributes || {}, N) === !1) return; - N += 1, A = new p(); + N += 1, w = new p(); } } - A.length() > 0 && f(A, {}, N); + w.length() > 0 && f(w, {}, N); } invert(f) { const m = new p(); - return this.reduce((y, A) => { - if (A.insert) - m.delete(o.default.length(A)); + return this.reduce((y, w) => { + if (w.insert) + m.delete(o.default.length(w)); else { - if (typeof A.retain == "number" && A.attributes == null) - return m.retain(A.retain), y + A.retain; - if (A.delete || typeof A.retain == "number") { - const N = A.delete || A.retain; - return f.slice(y, y + N).forEach((C) => { - A.delete ? m.push(C) : A.retain && A.attributes && m.retain(o.default.length(C), i.default.invert(A.attributes, C.attributes)); + if (typeof w.retain == "number" && w.attributes == null) + return m.retain(w.retain), y + w.retain; + if (w.delete || typeof w.retain == "number") { + const N = w.delete || w.retain; + return f.slice(y, y + N).forEach((q) => { + w.delete ? m.push(q) : w.retain && w.attributes && m.retain(o.default.length(q), i.default.invert(w.attributes, q.attributes)); }), y + N; - } else if (typeof A.retain == "object" && A.retain !== null) { - const N = f.slice(y, y + 1), x = new a.default(N.ops).next(), [C, M, z] = h(A.retain, x.insert), U = p.getHandler(C); - return m.retain({ [C]: U.invert(M, z) }, i.default.invert(A.attributes, x.attributes)), y + 1; + } else if (typeof w.retain == "object" && w.retain !== null) { + const N = f.slice(y, y + 1), x = new a.default(N.ops).next(), [q, B, H] = h(w.retain, x.insert), $ = p.getHandler(q); + return m.retain({ [q]: $.invert(B, H) }, i.default.invert(w.attributes, x.attributes)), y + 1; } } return y; @@ -3337,31 +3325,31 @@ function Bf() { transform(f, m = !1) { if (m = !!m, typeof f == "number") return this.transformPosition(f, m); - const y = f, A = new a.default(this.ops), N = new a.default(y.ops), x = new p(); - for (; A.hasNext() || N.hasNext(); ) - if (A.peekType() === "insert" && (m || N.peekType() !== "insert")) - x.retain(o.default.length(A.next())); + const y = f, w = new a.default(this.ops), N = new a.default(y.ops), x = new p(); + for (; w.hasNext() || N.hasNext(); ) + if (w.peekType() === "insert" && (m || N.peekType() !== "insert")) + x.retain(o.default.length(w.next())); else if (N.peekType() === "insert") x.push(N.next()); else { - const C = Math.min(A.peekLength(), N.peekLength()), M = A.next(C), z = N.next(C); - if (M.delete) + const q = Math.min(w.peekLength(), N.peekLength()), B = w.next(q), H = N.next(q); + if (B.delete) continue; - if (z.delete) - x.push(z); + if (H.delete) + x.push(H); else { - const U = M.retain, rt = z.retain; - let at = typeof rt == "object" && rt !== null ? rt : C; - if (typeof U == "object" && U !== null && typeof rt == "object" && rt !== null) { - const ft = Object.keys(U)[0]; - if (ft === Object.keys(rt)[0]) { - const Rt = p.getHandler(ft); - Rt && (at = { - [ft]: Rt.transform(U[ft], rt[ft], m) + const $ = B.retain, nt = H.retain; + let ot = typeof nt == "object" && nt !== null ? nt : q; + if (typeof $ == "object" && $ !== null && typeof nt == "object" && nt !== null) { + const ht = Object.keys($)[0]; + if (ht === Object.keys(nt)[0]) { + const Rt = p.getHandler(ht); + Rt && (ot = { + [ht]: Rt.transform($[ht], nt[ht], m) }); } } - x.retain(at, i.default.transform(M.attributes, z.attributes, m)); + x.retain(ot, i.default.transform(B.attributes, H.attributes, m)); } } return x.chop(); @@ -3369,24 +3357,24 @@ function Bf() { transformPosition(f, m = !1) { m = !!m; const y = new a.default(this.ops); - let A = 0; - for (; y.hasNext() && A <= f; ) { + let w = 0; + for (; y.hasNext() && w <= f; ) { const N = y.peekLength(), x = y.peekType(); if (y.next(), x === "delete") { - f -= Math.min(N, f - A); + f -= Math.min(N, f - w); continue; - } else x === "insert" && (A < f || !m) && (f += N); - A += N; + } else x === "insert" && (w < f || !m) && (f += N); + w += N; } return f; } } p.Op = o.default, p.OpIterator = a.default, p.AttributeMap = i.default, p.handlers = {}, t.default = p, r.exports = p, r.exports.default = p; - }(mr, mr.exports)), mr.exports; + })(lr, lr.exports)), lr.exports; } -var It = Bf(); -const B = /* @__PURE__ */ Dl(It); -class Ht extends Lt { +var Ot = gf(); +const k = /* @__PURE__ */ Nl(Ot); +class Ft extends Lt { static value() { } optimize() { @@ -3399,129 +3387,126 @@ class Ht extends Lt { return ""; } } -Ht.blotName = "break"; -Ht.tagName = "BR"; -let Ut = class extends Cr { +Ft.blotName = "break"; +Ft.tagName = "BR"; +let $t = class extends Er { }; -const Mf = { +const pf = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; -function Dr(r) { - return r.replace(/[&<>"']/g, (t) => Mf[t]); -} -const Yt = class Yt extends yi { +function _r(r) { + return r.replace(/[&<>"']/g, (t) => pf[t]); +} +class At extends ni { + static allowedChildren = [At, Ft, Lt, $t]; + // Lower index means deeper in the DOM tree, since not found (-1) is for embeds + static order = [ + "cursor", + "inline", + // Must be lower + "link", + // Chrome wants to be lower + "underline", + "strike", + "italic", + "bold", + "script", + "code" + // Must be higher + ]; static compare(t, e) { - const n = Yt.order.indexOf(t), s = Yt.order.indexOf(e); + const n = At.order.indexOf(t), s = At.order.indexOf(e); return n >= 0 || s >= 0 ? n - s : t === e ? 0 : t < e ? -1 : 1; } formatAt(t, e, n, s) { - if (Yt.compare(this.statics.blotName, n) < 0 && this.scroll.query(n, D.BLOT)) { + if (At.compare(this.statics.blotName, n) < 0 && this.scroll.query(n, M.BLOT)) { const i = this.isolate(t, e); s && i.wrap(n, s); } else super.formatAt(t, e, n, s); } optimize(t) { - if (super.optimize(t), this.parent instanceof Yt && Yt.compare(this.statics.blotName, this.parent.statics.blotName) > 0) { + if (super.optimize(t), this.parent instanceof At && At.compare(this.statics.blotName, this.parent.statics.blotName) > 0) { const e = this.parent.isolate(this.offset(), this.length()); this.moveChildren(e), e.wrap(this); } } -}; -O(Yt, "allowedChildren", [Yt, Ht, Lt, Ut]), // Lower index means deeper in the DOM tree, since not found (-1) is for embeds -O(Yt, "order", [ - "cursor", - "inline", - // Must be lower - "link", - // Chrome wants to be lower - "underline", - "strike", - "italic", - "bold", - "script", - "code" - // Must be higher -]); -let te = Yt; -const dl = 1; -class pt extends Un { - constructor() { - super(...arguments); - O(this, "cache", {}); - } +} +const Qi = 1; +class gt extends Rn { + cache = {}; delta() { - return this.cache.delta == null && (this.cache.delta = yo(this)), this.cache.delta; + return this.cache.delta == null && (this.cache.delta = ro(this)), this.cache.delta; } - deleteAt(e, n) { - super.deleteAt(e, n), this.cache = {}; + deleteAt(t, e) { + super.deleteAt(t, e), this.cache = {}; } - formatAt(e, n, s, i) { - n <= 0 || (this.scroll.query(s, D.BLOCK) ? e + n === this.length() && this.format(s, i) : super.formatAt(e, Math.min(n, this.length() - e - 1), s, i), this.cache = {}); + formatAt(t, e, n, s) { + e <= 0 || (this.scroll.query(n, M.BLOCK) ? t + e === this.length() && this.format(n, s) : super.formatAt(t, Math.min(e, this.length() - t - 1), n, s), this.cache = {}); } - insertAt(e, n, s) { - if (s != null) { - super.insertAt(e, n, s), this.cache = {}; + insertAt(t, e, n) { + if (n != null) { + super.insertAt(t, e, n), this.cache = {}; return; } - if (n.length === 0) return; - const i = n.split(` -`), o = i.shift(); - o.length > 0 && (e < this.length() - 1 || this.children.tail == null ? super.insertAt(Math.min(e, this.length() - 1), o) : this.children.tail.insertAt(this.children.tail.length(), o), this.cache = {}); - let a = this; - i.reduce((u, h) => (a = a.split(u, !0), a.insertAt(0, h), h.length), e + o.length); + if (e.length === 0) return; + const s = e.split(` +`), i = s.shift(); + i.length > 0 && (t < this.length() - 1 || this.children.tail == null ? super.insertAt(Math.min(t, this.length() - 1), i) : this.children.tail.insertAt(this.children.tail.length(), i), this.cache = {}); + let o = this; + s.reduce((a, u) => (o = o.split(a, !0), o.insertAt(0, u), u.length), t + i.length); } - insertBefore(e, n) { + insertBefore(t, e) { const { - head: s + head: n } = this.children; - super.insertBefore(e, n), s instanceof Ht && s.remove(), this.cache = {}; + super.insertBefore(t, e), n instanceof Ft && n.remove(), this.cache = {}; } length() { - return this.cache.length == null && (this.cache.length = super.length() + dl), this.cache.length; + return this.cache.length == null && (this.cache.length = super.length() + Qi), this.cache.length; } - moveChildren(e, n) { - super.moveChildren(e, n), this.cache = {}; + moveChildren(t, e) { + super.moveChildren(t, e), this.cache = {}; } - optimize(e) { - super.optimize(e), this.cache = {}; + optimize(t) { + super.optimize(t), this.cache = {}; } - path(e) { - return super.path(e, !0); + path(t) { + return super.path(t, !0); } - removeChild(e) { - super.removeChild(e), this.cache = {}; + removeChild(t) { + super.removeChild(t), this.cache = {}; } - split(e) { - let n = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : !1; - if (n && (e === 0 || e >= this.length() - dl)) { - const i = this.clone(); - return e === 0 ? (this.parent.insertBefore(i, this), this) : (this.parent.insertBefore(i, this.next), i); + split(t) { + let e = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : !1; + if (e && (t === 0 || t >= this.length() - Qi)) { + const s = this.clone(); + return t === 0 ? (this.parent.insertBefore(s, this), this) : (this.parent.insertBefore(s, this.next), s); } - const s = super.split(e, n); - return this.cache = {}, s; + const n = super.split(t, e); + return this.cache = {}, n; } } -pt.blotName = "block"; -pt.tagName = "P"; -pt.defaultChild = Ht; -pt.allowedChildren = [Ht, te, Lt, Ut]; -class Ct extends Lt { +gt.blotName = "block"; +gt.tagName = "P"; +gt.defaultChild = Ft; +gt.allowedChildren = [Ft, At, Lt, $t]; +class qt extends Lt { attach() { - super.attach(), this.attributes = new Br(this.domNode); + super.attach(), this.attributes = new Lr(this.domNode); } delta() { - return new B().insert(this.value(), { + return new k().insert(this.value(), { ...this.formats(), ...this.attributes.values() }); } format(t, e) { - const n = this.scroll.query(t, D.BLOCK_ATTRIBUTE); + const n = this.scroll.query(t, M.BLOCK_ATTRIBUTE); n != null && this.attributes.attribute(n, e); } formatAt(t, e, n, s) { @@ -3534,7 +3519,7 @@ class Ct extends Lt { } const s = e.split(` `), i = s.pop(), o = s.map((u) => { - const h = this.scroll.create(pt.blotName); + const h = this.scroll.create(gt.blotName); return h.insertAt(0, u), h; }), a = this.split(t); o.forEach((u) => { @@ -3542,25 +3527,29 @@ class Ct extends Lt { }), i && this.parent.insertBefore(this.scroll.create("text", i), a); } } -Ct.scope = D.BLOCK_BLOT; -function yo(r) { +qt.scope = M.BLOCK_BLOT; +function ro(r) { let t = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : !0; - return r.descendants(Et).reduce((e, n) => n.length() === 0 ? e : e.insert(n.value(), qt(n, {}, t)), new B()).insert(` -`, qt(r)); + return r.descendants(vt).reduce((e, n) => n.length() === 0 ? e : e.insert(n.value(), _t(n, {}, t)), new k()).insert(` +`, _t(r)); } -function qt(r) { +function _t(r) { let t = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : {}, e = arguments.length > 2 && arguments[2] !== void 0 ? arguments[2] : !0; return r == null || ("formats" in r && typeof r.formats == "function" && (t = { ...t, ...r.formats() - }, e && delete t["code-token"]), r.parent == null || r.parent.statics.blotName === "scroll" || r.parent.statics.scope !== r.statics.scope) ? t : qt(r.parent, t, e); + }, e && delete t["code-token"]), r.parent == null || r.parent.statics.blotName === "scroll" || r.parent.statics.scope !== r.statics.scope) ? t : _t(r.parent, t, e); } -const _t = class _t extends Lt { +class Dt extends Lt { + static blotName = "cursor"; + static className = "ql-cursor"; + static tagName = "span"; + static CONTENTS = "\uFEFF"; // Zero width no break space static value() { } constructor(t, e, n) { - super(t, e), this.selection = n, this.textNode = document.createTextNode(_t.CONTENTS), this.domNode.appendChild(this.textNode), this.savedLength = 0; + super(t, e), this.selection = n, this.textNode = document.createTextNode(Dt.CONTENTS), this.domNode.appendChild(this.textNode), this.savedLength = 0; } detach() { this.parent != null && this.parent.removeChild(this); @@ -3571,9 +3560,9 @@ const _t = class _t extends Lt { return; } let n = this, s = 0; - for (; n != null && n.statics.scope !== D.BLOCK_BLOT; ) + for (; n != null && n.statics.scope !== M.BLOCK_BLOT; ) s += n.offset(n.parent), n = n.parent; - n != null && (this.savedLength = _t.CONTENTS.length, n.optimize(), n.formatAt(s, _t.CONTENTS.length, t, e), this.savedLength = 0); + n != null && (this.savedLength = Dt.CONTENTS.length, n.optimize(), n.formatAt(s, Dt.CONTENTS.length, t, e), this.savedLength = 0); } index(t, e) { return t === this.textNode ? 0 : super.index(t, e); @@ -3592,10 +3581,10 @@ const _t = class _t extends Lt { const t = this.selection.getNativeRange(); for (; this.domNode.lastChild != null && this.domNode.lastChild !== this.textNode; ) this.domNode.parentNode.insertBefore(this.domNode.lastChild, this.domNode); - const e = this.prev instanceof Ut ? this.prev : null, n = e ? e.length() : 0, s = this.next instanceof Ut ? this.next : null, i = s ? s.text : "", { + const e = this.prev instanceof $t ? this.prev : null, n = e ? e.length() : 0, s = this.next instanceof $t ? this.next : null, i = s ? s.text : "", { textNode: o - } = this, a = o.data.split(_t.CONTENTS).join(""); - o.data = _t.CONTENTS; + } = this, a = o.data.split(Dt.CONTENTS).join(""); + o.data = Dt.CONTENTS; let u; if (e) u = e, (a || s) && (e.insertAt(e.length(), a + i), s && s.remove()); @@ -3640,7 +3629,7 @@ const _t = class _t extends Lt { } = this; for (; e; ) { if (e.domNode.tagName === "A") { - this.savedLength = _t.CONTENTS.length, e.isolate(this.offset(e), this.length()).unwrap(), this.savedLength = 0; + this.savedLength = Dt.CONTENTS.length, e.isolate(this.offset(e), this.length()).unwrap(), this.savedLength = 0; break; } e = e.parent; @@ -3649,12 +3638,10 @@ const _t = class _t extends Lt { value() { return ""; } -}; -O(_t, "blotName", "cursor"), O(_t, "className", "ql-cursor"), O(_t, "tagName", "span"), O(_t, "CONTENTS", "\uFEFF"); -let an = _t; -var ks = { exports: {} }, gl; -function Df() { - return gl || (gl = 1, function(r) { +} +var xs = { exports: {} }, Ji; +function mf() { + return Ji || (Ji = 1, (function(r) { var t = Object.prototype.hasOwnProperty, e = "~"; function n() { } @@ -3691,9 +3678,9 @@ function Df() { var p = e ? e + h : h, v = this._events[p]; return v ? v.fn ? 1 : v.length : 0; }, a.prototype.emit = function(h, p, v, f, m, y) { - var A = e ? e + h : h; - if (!this._events[A]) return !1; - var N = this._events[A], x = arguments.length, C, M; + var w = e ? e + h : h; + if (!this._events[w]) return !1; + var N = this._events[w], x = arguments.length, q, B; if (N.fn) { switch (N.once && this.removeListener(h, N.fn, void 0, !0), x) { case 1: @@ -3709,29 +3696,29 @@ function Df() { case 6: return N.fn.call(N.context, p, v, f, m, y), !0; } - for (M = 1, C = new Array(x - 1); M < x; M++) - C[M - 1] = arguments[M]; - N.fn.apply(N.context, C); + for (B = 1, q = new Array(x - 1); B < x; B++) + q[B - 1] = arguments[B]; + N.fn.apply(N.context, q); } else { - var z = N.length, U; - for (M = 0; M < z; M++) - switch (N[M].once && this.removeListener(h, N[M].fn, void 0, !0), x) { + var H = N.length, $; + for (B = 0; B < H; B++) + switch (N[B].once && this.removeListener(h, N[B].fn, void 0, !0), x) { case 1: - N[M].fn.call(N[M].context); + N[B].fn.call(N[B].context); break; case 2: - N[M].fn.call(N[M].context, p); + N[B].fn.call(N[B].context, p); break; case 3: - N[M].fn.call(N[M].context, p, v); + N[B].fn.call(N[B].context, p, v); break; case 4: - N[M].fn.call(N[M].context, p, v, f); + N[B].fn.call(N[B].context, p, v, f); break; default: - if (!C) for (U = 1, C = new Array(x - 1); U < x; U++) - C[U - 1] = arguments[U]; - N[M].fn.apply(N[M].context, C); + if (!q) for ($ = 1, q = new Array(x - 1); $ < x; $++) + q[$ - 1] = arguments[$]; + N[B].fn.apply(N[B].context, q); } } return !0; @@ -3748,8 +3735,8 @@ function Df() { if (y.fn) y.fn === p && (!f || y.once) && (!v || y.context === v) && o(this, m); else { - for (var A = 0, N = [], x = y.length; A < x; A++) - (y[A].fn !== p || f && !y[A].once || v && y[A].context !== v) && N.push(y[A]); + for (var w = 0, N = [], x = y.length; w < x; w++) + (y[w].fn !== p || f && !y[w].once || v && y[w].context !== v) && N.push(y[w]); N.length ? this._events[m] = N.length === 1 ? N[0] : N : o(this, m); } return this; @@ -3757,44 +3744,64 @@ function Df() { var p; return h ? (p = e ? e + h : h, this._events[p] && o(this, p)) : (this._events = new n(), this._eventsCount = 0), this; }, a.prototype.off = a.prototype.removeListener, a.prototype.addListener = a.prototype.on, a.prefixed = e, a.EventEmitter = a, r.exports = a; - }(ks)), ks.exports; + })(xs)), xs.exports; } -var jf = Df(); -const $f = /* @__PURE__ */ Dl(jf), ei = /* @__PURE__ */ new WeakMap(), ni = ["error", "warn", "log", "info"]; -let ri = "warn"; -function vo(r) { - if (ri && ni.indexOf(r) <= ni.indexOf(ri)) { +var bf = mf(); +const yf = /* @__PURE__ */ Nl(bf), Gs = /* @__PURE__ */ new WeakMap(), Vs = ["error", "warn", "log", "info"]; +let Ws = "warn"; +function so(r) { + if (Ws && Vs.indexOf(r) <= Vs.indexOf(Ws)) { for (var t = arguments.length, e = new Array(t > 1 ? t - 1 : 0), n = 1; n < t; n++) e[n - 1] = arguments[n]; console[r](...e); } } -function he(r) { - return ni.reduce((t, e) => (t[e] = vo.bind(console, e, r), t), {}); +function ie(r) { + return Vs.reduce((t, e) => (t[e] = so.bind(console, e, r), t), {}); } -he.level = (r) => { - ri = r; +ie.level = (r) => { + Ws = r; }; -vo.level = he.level; -const Bs = he("quill:events"), Pf = ["selectionchange", "mousedown", "mouseup", "click"]; -Pf.forEach((r) => { +so.level = ie.level; +const Ls = ie("quill:events"), vf = ["selectionchange", "mousedown", "mouseup", "click"]; +vf.forEach((r) => { document.addEventListener(r, function() { for (var t = arguments.length, e = new Array(t), n = 0; n < t; n++) e[n] = arguments[n]; Array.from(document.querySelectorAll(".ql-container")).forEach((s) => { - const i = ei.get(s); + const i = Gs.get(s); i && i.emitter && i.emitter.handleDOM(...e); }); }); }); -class R extends $f { +class R extends yf { + static events = { + EDITOR_CHANGE: "editor-change", + SCROLL_BEFORE_UPDATE: "scroll-before-update", + SCROLL_BLOT_MOUNT: "scroll-blot-mount", + SCROLL_BLOT_UNMOUNT: "scroll-blot-unmount", + SCROLL_OPTIMIZE: "scroll-optimize", + SCROLL_UPDATE: "scroll-update", + SCROLL_EMBED_UPDATE: "scroll-embed-update", + SELECTION_CHANGE: "selection-change", + TEXT_CHANGE: "text-change", + COMPOSITION_BEFORE_START: "composition-before-start", + COMPOSITION_START: "composition-start", + COMPOSITION_BEFORE_END: "composition-before-end", + COMPOSITION_END: "composition-end" + }; + static sources = { + API: "api", + SILENT: "silent", + USER: "user" + }; constructor() { - super(), this.domListeners = {}, this.on("error", Bs.error); + super(), this.domListeners = {}, this.on("error", Ls.error); } emit() { for (var t = arguments.length, e = new Array(t), n = 0; n < t; n++) e[n] = arguments[n]; - return Bs.log.call(Bs, ...e), super.emit(...e); + return Ls.log.call(Ls, ...e), super.emit(...e); } handleDOM(t) { for (var e = arguments.length, n = new Array(e > 1 ? e - 1 : 0), s = 1; s < e; s++) @@ -3814,35 +3821,16 @@ class R extends $f { }); } } -O(R, "events", { - EDITOR_CHANGE: "editor-change", - SCROLL_BEFORE_UPDATE: "scroll-before-update", - SCROLL_BLOT_MOUNT: "scroll-blot-mount", - SCROLL_BLOT_UNMOUNT: "scroll-blot-unmount", - SCROLL_OPTIMIZE: "scroll-optimize", - SCROLL_UPDATE: "scroll-update", - SCROLL_EMBED_UPDATE: "scroll-embed-update", - SELECTION_CHANGE: "selection-change", - TEXT_CHANGE: "text-change", - COMPOSITION_BEFORE_START: "composition-before-start", - COMPOSITION_START: "composition-start", - COMPOSITION_BEFORE_END: "composition-before-end", - COMPOSITION_END: "composition-end" -}), O(R, "sources", { - API: "api", - SILENT: "silent", - USER: "user" -}); -const Ms = he("quill:selection"); -class Ie { +const Ss = ie("quill:selection"); +class Se { constructor(t) { let e = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : 0; this.index = t, this.length = e; } } -class Uf { +class Ef { constructor(t, e) { - this.emitter = e, this.scroll = t, this.composing = !1, this.mouseDown = !1, this.root = this.scroll.domNode, this.cursor = this.scroll.create("cursor", this), this.savedRange = new Ie(0, 0), this.lastRange = this.savedRange, this.lastNative = null, this.handleComposition(), this.handleDragging(), this.emitter.listenDOM("selectionchange", document, () => { + this.emitter = e, this.scroll = t, this.composing = !1, this.mouseDown = !1, this.root = this.scroll.domNode, this.cursor = this.scroll.create("cursor", this), this.savedRange = new Se(0, 0), this.lastRange = this.savedRange, this.lastNative = null, this.handleComposition(), this.handleDragging(), this.emitter.listenDOM("selectionchange", document, () => { !this.mouseDown && !this.composing && setTimeout(this.update.bind(this, R.sources.USER), 1); }), this.emitter.on(R.events.SCROLL_BEFORE_UPDATE, () => { if (!this.hasFocus()) return; @@ -3895,11 +3883,11 @@ class Uf { format(t, e) { this.scroll.update(); const n = this.getNativeRange(); - if (!(n == null || !n.native.collapsed || this.scroll.query(t, D.BLOCK))) { + if (!(n == null || !n.native.collapsed || this.scroll.query(t, M.BLOCK))) { if (n.start.node !== this.cursor.textNode) { const s = this.scroll.find(n.start.node, !1); if (s == null) return; - if (s instanceof Et) { + if (s instanceof vt) { const i = s.split(n.start.offset); s.parent.insertBefore(this.cursor, i); } else @@ -3950,7 +3938,7 @@ class Uf { const e = t.getRangeAt(0); if (e == null) return null; const n = this.normalizeNative(e); - return Ms.info("getNativeRange", n), n; + return Ss.info("getNativeRange", n), n; } getRange() { const t = this.scroll.domNode; @@ -3960,19 +3948,19 @@ class Uf { return e == null ? [null, null] : [this.normalizedToRange(e), e]; } hasFocus() { - return document.activeElement === this.root || document.activeElement != null && Ds(this.root, document.activeElement); + return document.activeElement === this.root || document.activeElement != null && _s(this.root, document.activeElement); } normalizedToRange(t) { const e = [[t.start.node, t.start.offset]]; t.native.collapsed || e.push([t.end.node, t.end.offset]); const n = e.map((o) => { const [a, u] = o, h = this.scroll.find(a, !0), p = h.offset(this.scroll); - return u === 0 ? p : h instanceof Et ? p + h.index(a, u) : p + h.length(); + return u === 0 ? p : h instanceof vt ? p + h.index(a, u) : p + h.length(); }), s = Math.min(Math.max(...n), this.scroll.length() - 1), i = Math.min(s, ...n); - return new Ie(i, s - i); + return new Se(i, s - i); } normalizeNative(t) { - if (!Ds(this.root, t.startContainer) || !t.collapsed && !Ds(this.root, t.endContainer)) + if (!_s(this.root, t.startContainer) || !t.collapsed && !_s(this.root, t.endContainer)) return null; const e = { start: { @@ -4010,7 +3998,7 @@ class Uf { } setNativeRange(t, e) { let n = arguments.length > 2 && arguments[2] !== void 0 ? arguments[2] : t, s = arguments.length > 3 && arguments[3] !== void 0 ? arguments[3] : e, i = arguments.length > 4 && arguments[4] !== void 0 ? arguments[4] : !1; - if (Ms.info("setNativeRange", t, e, n, s), t != null && (this.root.parentNode == null || t.parentNode == null || // @ts-expect-error Fix me later + if (Ss.info("setNativeRange", t, e, n, s), t != null && (this.root.parentNode == null || t.parentNode == null || // @ts-expect-error Fix me later n.parentNode == null)) return; const o = document.getSelection(); @@ -4032,7 +4020,7 @@ class Uf { } setRange(t) { let e = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : !1, n = arguments.length > 2 && arguments[2] !== void 0 ? arguments[2] : R.sources.API; - if (typeof e == "string" && (n = e, e = !1), Ms.info("setRange", t), t != null) { + if (typeof e == "string" && (n = e, e = !1), Ss.info("setRange", t), t != null) { const s = this.rangeToNative(t); this.setNativeRange(...s, e); } else @@ -4042,17 +4030,17 @@ class Uf { update() { let t = arguments.length > 0 && arguments[0] !== void 0 ? arguments[0] : R.sources.USER; const e = this.lastRange, [n, s] = this.getRange(); - if (this.lastRange = n, this.lastNative = s, this.lastRange != null && (this.savedRange = this.lastRange), !pe(e, this.lastRange)) { + if (this.lastRange = n, this.lastNative = s, this.lastRange != null && (this.savedRange = this.lastRange), !ce(e, this.lastRange)) { if (!this.composing && s != null && s.native.collapsed && s.start.node !== this.cursor.textNode) { const o = this.cursor.restore(); o && this.setNativeRange(o.startNode, o.startOffset, o.endNode, o.endOffset); } - const i = [R.events.SELECTION_CHANGE, nn(this.lastRange), nn(e), t]; + const i = [R.events.SELECTION_CHANGE, Xe(this.lastRange), Xe(e), t]; this.emitter.emit(R.events.EDITOR_CHANGE, ...i), t !== R.sources.SILENT && this.emitter.emit(...i); } } } -function Ds(r, t) { +function _s(r, t) { try { t.parentNode; } catch { @@ -4060,8 +4048,8 @@ function Ds(r, t) { } return r.contains(t); } -const Ff = /^[ -~]*$/; -class Hf { +const Af = /^[ -~]*$/; +class wf { constructor(t) { this.scroll = t, this.delta = this.getDelta(); } @@ -4069,38 +4057,38 @@ class Hf { this.scroll.update(); let e = this.scroll.length(); this.scroll.batchStart(); - const n = pl(t), s = new B(); - return Kf(n.ops.slice()).reduce((o, a) => { - const u = It.Op.length(a); + const n = tl(t), s = new k(); + return Tf(n.ops.slice()).reduce((o, a) => { + const u = Ot.Op.length(a); let h = a.attributes || {}, p = !1, v = !1; if (a.insert != null) { if (s.retain(u), typeof a.insert == "string") { const y = a.insert; v = !y.endsWith(` -`) && (e <= o || !!this.scroll.descendant(Ct, o)[0]), this.scroll.insertAt(o, y); - const [A, N] = this.scroll.line(o); - let x = ve({}, qt(A)); - if (A instanceof pt) { - const [C] = A.descendant(Et, N); - C && (x = ve(x, qt(C))); +`) && (e <= o || !!this.scroll.descendant(qt, o)[0]), this.scroll.insertAt(o, y); + const [w, N] = this.scroll.line(o); + let x = fe({}, _t(w)); + if (w instanceof gt) { + const [q] = w.descendant(vt, N); + q && (x = fe(x, _t(q))); } - h = It.AttributeMap.diff(x, h) || {}; + h = Ot.AttributeMap.diff(x, h) || {}; } else if (typeof a.insert == "object") { const y = Object.keys(a.insert)[0]; if (y == null) return o; - const A = this.scroll.query(y, D.INLINE) != null; - if (A) - (e <= o || this.scroll.descendant(Ct, o)[0]) && (v = !0); + const w = this.scroll.query(y, M.INLINE) != null; + if (w) + (e <= o || this.scroll.descendant(qt, o)[0]) && (v = !0); else if (o > 0) { - const [N, x] = this.scroll.descendant(Et, o - 1); - N instanceof Ut ? N.value()[x] !== ` -` && (p = !0) : N instanceof Lt && N.statics.scope === D.INLINE_BLOT && (p = !0); + const [N, x] = this.scroll.descendant(vt, o - 1); + N instanceof $t ? N.value()[x] !== ` +` && (p = !0) : N instanceof Lt && N.statics.scope === M.INLINE_BLOT && (p = !0); } - if (this.scroll.insertAt(o, y, a.insert[y]), A) { - const [N] = this.scroll.descendant(Et, o); + if (this.scroll.insertAt(o, y, a.insert[y]), w) { + const [N] = this.scroll.descendant(vt, o); if (N) { - const x = ve({}, qt(N)); - h = It.AttributeMap.diff(x, h) || {}; + const x = fe({}, _t(N)); + h = Ot.AttributeMap.diff(x, h) || {}; } } } @@ -4115,10 +4103,10 @@ class Hf { }); const f = p ? 1 : 0, m = v ? 1 : 0; return e += f + m, s.retain(f), s.delete(m), o + u + f + m; - }, 0), s.reduce((o, a) => typeof a.delete == "number" ? (this.scroll.deleteAt(o, a.delete), o) : o + It.Op.length(a), 0), this.scroll.batchEnd(), this.scroll.optimize(), this.update(n); + }, 0), s.reduce((o, a) => typeof a.delete == "number" ? (this.scroll.deleteAt(o, a.delete), o) : o + Ot.Op.length(a), 0), this.scroll.batchEnd(), this.scroll.optimize(), this.update(n); } deleteText(t, e) { - return this.scroll.deleteAt(t, e), this.update(new B().retain(t).delete(e)); + return this.scroll.deleteAt(t, e), this.update(new k().retain(t).delete(e)); } formatLine(t, e) { let n = arguments.length > 2 && arguments[2] !== void 0 ? arguments[2] : {}; @@ -4127,7 +4115,7 @@ class Hf { o.format(i, n[i]); }); }), this.scroll.optimize(); - const s = new B().retain(t).retain(e, nn(n)); + const s = new k().retain(t).retain(e, Xe(n)); return this.update(s); } formatText(t, e) { @@ -4135,29 +4123,29 @@ class Hf { Object.keys(n).forEach((i) => { this.scroll.formatAt(t, e, i, n[i]); }); - const s = new B().retain(t).retain(e, nn(n)); + const s = new k().retain(t).retain(e, Xe(n)); return this.update(s); } getContents(t, e) { return this.delta.slice(t, t + e); } getDelta() { - return this.scroll.lines().reduce((t, e) => t.concat(e.delta()), new B()); + return this.scroll.lines().reduce((t, e) => t.concat(e.delta()), new k()); } getFormat(t) { let e = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : 0, n = [], s = []; e === 0 ? this.scroll.path(t).forEach((a) => { const [u] = a; - u instanceof pt ? n.push(u) : u instanceof Et && s.push(u); - }) : (n = this.scroll.lines(t, e), s = this.scroll.descendants(Et, t, e)); + u instanceof gt ? n.push(u) : u instanceof vt && s.push(u); + }) : (n = this.scroll.lines(t, e), s = this.scroll.descendants(vt, t, e)); const [i, o] = [n, s].map((a) => { const u = a.shift(); if (u == null) return {}; - let h = qt(u); + let h = _t(u); for (; Object.keys(h).length > 0; ) { const p = a.shift(); if (p == null) return h; - h = zf(qt(p), h); + h = Nf(_t(p), h); } return h; }); @@ -4170,7 +4158,7 @@ class Hf { const [n, s] = this.scroll.line(t); if (n) { const i = n.length(); - return n.length() >= s + e && !(s === 0 && e === i) ? Fn(n, s, e, !0) : Fn(this.scroll, t, e, !0); + return n.length() >= s + e && !(s === 0 && e === i) ? In(n, s, e, !0) : In(this.scroll, t, e, !0); } return ""; } @@ -4178,11 +4166,11 @@ class Hf { return this.getContents(t, e).filter((n) => typeof n.insert == "string").map((n) => n.insert).join(""); } insertContents(t, e) { - const n = pl(e), s = new B().retain(t).concat(n); + const n = tl(e), s = new k().retain(t).concat(n); return this.scroll.insertContents(t, n), this.update(s); } insertEmbed(t, e, n) { - return this.scroll.insertAt(t, e, n), this.update(new B().retain(t).insert({ + return this.scroll.insertAt(t, e, n), this.update(new k().retain(t).insert({ [e]: n })); } @@ -4192,43 +4180,43 @@ class Hf { `).replace(/\r/g, ` `), this.scroll.insertAt(t, e), Object.keys(n).forEach((s) => { this.scroll.formatAt(t, e.length, s, n[s]); - }), this.update(new B().retain(t).insert(e, nn(n))); + }), this.update(new k().retain(t).insert(e, Xe(n))); } isBlank() { if (this.scroll.children.length === 0) return !0; if (this.scroll.children.length > 1) return !1; const t = this.scroll.children.head; - if ((t == null ? void 0 : t.statics.blotName) !== pt.blotName) return !1; + if (t?.statics.blotName !== gt.blotName) return !1; const e = t; - return e.children.length > 1 ? !1 : e.children.head instanceof Ht; + return e.children.length > 1 ? !1 : e.children.head instanceof Ft; } removeFormat(t, e) { const n = this.getText(t, e), [s, i] = this.scroll.line(t + e); - let o = 0, a = new B(); + let o = 0, a = new k(); s != null && (o = s.length() - i, a = s.delta().slice(i, i + o - 1).insert(` `)); - const h = this.getContents(t, e + o).diff(new B().insert(n).concat(a)), p = new B().retain(t).concat(h); + const h = this.getContents(t, e + o).diff(new k().insert(n).concat(a)), p = new k().retain(t).concat(h); return this.applyDelta(p); } update(t) { let e = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : [], n = arguments.length > 2 && arguments[2] !== void 0 ? arguments[2] : void 0; const s = this.delta; if (e.length === 1 && e[0].type === "characterData" && // @ts-expect-error Fix me later - e[0].target.data.match(Ff) && this.scroll.find(e[0].target)) { - const i = this.scroll.find(e[0].target), o = qt(i), a = i.offset(this.scroll), u = e[0].oldValue.replace(an.CONTENTS, ""), h = new B().insert(u), p = new B().insert(i.value()), v = n && { - oldRange: ml(n.oldRange, -a), - newRange: ml(n.newRange, -a) + e[0].target.data.match(Af) && this.scroll.find(e[0].target)) { + const i = this.scroll.find(e[0].target), o = _t(i), a = i.offset(this.scroll), u = e[0].oldValue.replace(Dt.CONTENTS, ""), h = new k().insert(u), p = new k().insert(i.value()), v = n && { + oldRange: el(n.oldRange, -a), + newRange: el(n.newRange, -a) }; - t = new B().retain(a).concat(h.diff(p, v)).reduce((m, y) => y.insert ? m.insert(y.insert, o) : m.push(y), new B()), this.delta = s.compose(t); + t = new k().retain(a).concat(h.diff(p, v)).reduce((m, y) => y.insert ? m.insert(y.insert, o) : m.push(y), new k()), this.delta = s.compose(t); } else - this.delta = this.getDelta(), (!t || !pe(s.compose(t), this.delta)) && (t = s.diff(this.delta, n)); + this.delta = this.getDelta(), (!t || !ce(s.compose(t), this.delta)) && (t = s.diff(this.delta, n)); return t; } } -function tn(r, t, e) { +function Ye(r, t, e) { if (r.length === 0) { - const [m] = js(e.pop()); - return t <= 0 ? `` : `${tn([], t - 1, e)}`; + const [m] = Cs(e.pop()); + return t <= 0 ? `` : `${Ye([], t - 1, e)}`; } const [{ child: n, @@ -4236,21 +4224,21 @@ function tn(r, t, e) { length: i, indent: o, type: a - }, ...u] = r, [h, p] = js(a); + }, ...u] = r, [h, p] = Cs(a); if (o > t) - return e.push(a), o === t + 1 ? `<${h}>${Fn(n, s, i)}${tn(u, o, e)}` : `<${h}>
  • ${tn(r, t + 1, e)}`; + return e.push(a), o === t + 1 ? `<${h}>${In(n, s, i)}${Ye(u, o, e)}` : `<${h}>
  • ${Ye(r, t + 1, e)}`; const v = e[e.length - 1]; if (o === t && a === v) - return `
  • ${Fn(n, s, i)}${tn(u, o, e)}`; - const [f] = js(e.pop()); - return `${tn(r, t - 1, e)}`; + return `${In(n, s, i)}${Ye(u, o, e)}`; + const [f] = Cs(e.pop()); + return `${Ye(r, t - 1, e)}`; } -function Fn(r, t, e) { +function In(r, t, e) { let n = arguments.length > 3 && arguments[3] !== void 0 ? arguments[3] : !1; if ("html" in r && typeof r.html == "function") return r.html(t, e); - if (r instanceof Ut) - return Dr(r.value().slice(t, t + e)).replaceAll(" ", " "); + if (r instanceof $t) + return _r(r.value().slice(t, t + e)).replaceAll(" ", " "); if (r instanceof Pt) { if (r.statics.blotName === "list-container") { const h = []; @@ -4263,11 +4251,11 @@ function Fn(r, t, e) { indent: m.indent || 0, type: m.list }); - }), tn(h, -1, []); + }), Ye(h, -1, []); } const s = []; if (r.children.forEachAt(t, e, (h, p, v) => { - s.push(Fn(h, p, v)); + s.push(In(h, p, v)); }), n || r.statics.blotName === "list") return s.join(""); const { @@ -4278,14 +4266,14 @@ function Fn(r, t, e) { } return r.domNode instanceof Element ? r.domNode.outerHTML : ""; } -function zf(r, t) { +function Nf(r, t) { return Object.keys(t).reduce((e, n) => { if (r[n] == null) return e; const s = t[n]; return s === r[n] ? e[n] = s : Array.isArray(s) ? s.indexOf(r[n]) < 0 ? e[n] = s.concat([r[n]]) : e[n] = s : e[n] = [s, r[n]], e; }, {}); } -function js(r) { +function Cs(r) { const t = r === "ordered" ? "ol" : "ul"; switch (r) { case "checked": @@ -4296,7 +4284,7 @@ function js(r) { return [t, ""]; } } -function pl(r) { +function tl(r) { return r.reduce((t, e) => { if (typeof e.insert == "string") { const n = e.insert.replace(/\r\n/g, ` @@ -4305,16 +4293,16 @@ function pl(r) { return t.insert(n, e.attributes); } return t.push(e); - }, new B()); + }, new k()); } -function ml(r, t) { +function el(r, t) { let { index: e, length: n } = r; - return new Ie(e + t, n); + return new Se(e + t, n); } -function Kf(r) { +function Tf(r) { const t = []; return r.forEach((e) => { typeof e.insert == "string" ? e.insert.split(` @@ -4330,28 +4318,28 @@ function Kf(r) { }) : t.push(e); }), t; } -class zt { +class Yt { + static DEFAULTS = {}; constructor(t) { let e = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : {}; this.quill = t, this.options = e; } } -O(zt, "DEFAULTS", {}); -const Er = "\uFEFF"; -class Ei extends Lt { +const ur = "\uFEFF"; +class si extends Lt { constructor(t, e) { super(t, e), this.contentNode = document.createElement("span"), this.contentNode.setAttribute("contenteditable", "false"), Array.from(this.domNode.childNodes).forEach((n) => { this.contentNode.appendChild(n); - }), this.leftGuard = document.createTextNode(Er), this.rightGuard = document.createTextNode(Er), this.domNode.appendChild(this.leftGuard), this.domNode.appendChild(this.contentNode), this.domNode.appendChild(this.rightGuard); + }), this.leftGuard = document.createTextNode(ur), this.rightGuard = document.createTextNode(ur), this.domNode.appendChild(this.leftGuard), this.domNode.appendChild(this.contentNode), this.domNode.appendChild(this.rightGuard); } index(t, e) { return t === this.leftGuard ? 0 : t === this.rightGuard ? 1 : super.index(t, e); } restore(t) { let e = null, n; - const s = t.data.split(Er).join(""); + const s = t.data.split(ur).join(""); if (t === this.leftGuard) - if (this.prev instanceof Ut) { + if (this.prev instanceof $t) { const i = this.prev.length(); this.prev.insertAt(i, s), e = { startNode: this.prev.domNode, @@ -4362,14 +4350,14 @@ class Ei extends Lt { startNode: n, startOffset: s.length }; - else t === this.rightGuard && (this.next instanceof Ut ? (this.next.insertAt(0, s), e = { + else t === this.rightGuard && (this.next instanceof $t ? (this.next.insertAt(0, s), e = { startNode: this.next.domNode, startOffset: s.length }) : (n = document.createTextNode(s), this.parent.insertBefore(this.scroll.create(n), this.next), e = { startNode: n, startOffset: s.length })); - return t.data = Er, e; + return t.data = ur, e; } update(t, e) { t.forEach((n) => { @@ -4380,9 +4368,9 @@ class Ei extends Lt { }); } } -class Gf { +class xf { + isComposing = !1; constructor(t, e) { - O(this, "isComposing", !1); this.scroll = t, this.emitter = e, this.setupListeners(); } setupListeners() { @@ -4396,15 +4384,21 @@ class Gf { } handleCompositionStart(t) { const e = t.target instanceof Node ? this.scroll.find(t.target, !0) : null; - e && !(e instanceof Ei) && (this.emitter.emit(R.events.COMPOSITION_BEFORE_START, t), this.scroll.batchStart(), this.emitter.emit(R.events.COMPOSITION_START, t), this.isComposing = !0); + e && !(e instanceof si) && (this.emitter.emit(R.events.COMPOSITION_BEFORE_START, t), this.scroll.batchStart(), this.emitter.emit(R.events.COMPOSITION_START, t), this.isComposing = !0); } handleCompositionEnd(t) { this.emitter.emit(R.events.COMPOSITION_BEFORE_END, t), this.scroll.batchEnd(), this.emitter.emit(R.events.COMPOSITION_END, t), this.isComposing = !1; } } -const Mn = class Mn { +class rn { + static DEFAULTS = { + modules: {} + }; + static themes = { + default: rn + }; + modules = {}; constructor(t, e) { - O(this, "modules", {}); this.quill = t, this.options = e; } init() { @@ -4416,14 +4410,8 @@ const Mn = class Mn { const e = this.quill.constructor.import(`modules/${t}`); return this.modules[t] = new e(this.quill, this.options.modules[t] || {}), this.modules[t]; } -}; -O(Mn, "DEFAULTS", { - modules: {} -}), O(Mn, "themes", { - default: Mn -}); -let cn = Mn; -const Vf = (r) => r.parentElement || r.getRootNode().host || null, Wf = (r) => { +} +const Lf = (r) => r.parentElement || r.getRootNode().host || null, Sf = (r) => { const t = r.getBoundingClientRect(), e = "offsetWidth" in r && Math.abs(t.width) / r.offsetWidth || 1, n = "offsetHeight" in r && Math.abs(t.height) / r.offsetHeight || 1; return { top: t.top, @@ -4431,42 +4419,41 @@ const Vf = (r) => r.parentElement || r.getRootNode().host || null, Wf = (r) => { bottom: t.top + r.clientHeight * n, left: t.left }; -}, Ar = (r) => { +}, hr = (r) => { const t = parseInt(r, 10); return Number.isNaN(t) ? 0 : t; -}, bl = (r, t, e, n, s, i) => r < e && t > n ? 0 : r < e ? -(e - r + s) : t > n ? t - r > n - e ? r + s - e : t - n + i : 0, Zf = (r, t) => { - var i, o, a; +}, nl = (r, t, e, n, s, i) => r < e && t > n ? 0 : r < e ? -(e - r + s) : t > n ? t - r > n - e ? r + s - e : t - n + i : 0, _f = (r, t) => { const e = r.ownerDocument; let n = t, s = r; for (; s; ) { - const u = s === e.body, h = u ? { + const i = s === e.body, o = i ? { top: 0, - right: ((i = window.visualViewport) == null ? void 0 : i.width) ?? e.documentElement.clientWidth, - bottom: ((o = window.visualViewport) == null ? void 0 : o.height) ?? e.documentElement.clientHeight, + right: window.visualViewport?.width ?? e.documentElement.clientWidth, + bottom: window.visualViewport?.height ?? e.documentElement.clientHeight, left: 0 - } : Wf(s), p = getComputedStyle(s), v = bl(n.left, n.right, h.left, h.right, Ar(p.scrollPaddingLeft), Ar(p.scrollPaddingRight)), f = bl(n.top, n.bottom, h.top, h.bottom, Ar(p.scrollPaddingTop), Ar(p.scrollPaddingBottom)); - if (v || f) - if (u) - (a = e.defaultView) == null || a.scrollBy(v, f); + } : Sf(s), a = getComputedStyle(s), u = nl(n.left, n.right, o.left, o.right, hr(a.scrollPaddingLeft), hr(a.scrollPaddingRight)), h = nl(n.top, n.bottom, o.top, o.bottom, hr(a.scrollPaddingTop), hr(a.scrollPaddingBottom)); + if (u || h) + if (i) + e.defaultView?.scrollBy(u, h); else { const { - scrollLeft: m, - scrollTop: y + scrollLeft: p, + scrollTop: v } = s; - f && (s.scrollTop += f), v && (s.scrollLeft += v); - const A = s.scrollLeft - m, N = s.scrollTop - y; + h && (s.scrollTop += h), u && (s.scrollLeft += u); + const f = s.scrollLeft - p, m = s.scrollTop - v; n = { - left: n.left - A, - top: n.top - N, - right: n.right - A, - bottom: n.bottom - N + left: n.left - f, + top: n.top - m, + right: n.right - f, + bottom: n.bottom - m }; } - s = u || p.position === "fixed" ? null : Vf(s); + s = i || a.position === "fixed" ? null : Lf(s); } -}, Yf = 100, Xf = ["block", "break", "cursor", "inline", "scroll", "text"], Qf = (r, t, e) => { - const n = new on(); - return Xf.forEach((s) => { +}, Cf = 100, qf = ["block", "break", "cursor", "inline", "scroll", "text"], Of = (r, t, e) => { + const n = new en(); + return qf.forEach((s) => { const i = t.query(s); i && n.register(i); }), r.forEach((s) => { @@ -4474,23 +4461,45 @@ const Vf = (r) => r.parentElement || r.getRootNode().host || null, Wf = (r) => { i || e.error(`Cannot register "${s}" specified in "formats" config. Are you sure it was registered?`); let o = 0; for (; i; ) - if (n.register(i), i = "blotName" in i ? i.requiredContainer ?? null : null, o += 1, o > Yf) { + if (n.register(i), i = "blotName" in i ? i.requiredContainer ?? null : null, o += 1, o > Cf) { e.error(`Cycle detected in registering blot requiredContainer: "${s}"`); break; } }), n; -}, sn = he("quill"), wr = new on(); +}, Je = ie("quill"), fr = new en(); Pt.uiClass = "ql-ui"; -const Dt = class Dt { +class A { + static DEFAULTS = { + bounds: null, + modules: { + clipboard: !0, + keyboard: !0, + history: !0, + uploader: !0 + }, + placeholder: "", + readOnly: !1, + registry: fr, + theme: "default" + }; + static events = R.events; + static sources = R.sources; + static version = "2.0.3"; + static imports = { + delta: k, + parchment: uf, + "core/module": Yt, + "core/theme": rn + }; static debug(t) { - t === !0 && (t = "log"), he.level(t); + t === !0 && (t = "log"), ie.level(t); } static find(t) { let e = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : !1; - return ei.get(t) || wr.find(t, e); + return Gs.get(t) || fr.find(t, e); } static import(t) { - return this.imports[t] == null && sn.error(`Cannot import ${t}. Are you sure it was registered?`), this.imports[t]; + return this.imports[t] == null && Je.error(`Cannot import ${t}. Are you sure it was registered?`), this.imports[t]; } static register() { if (typeof (arguments.length <= 0 ? void 0 : arguments[0]) != "string") { @@ -4500,42 +4509,42 @@ const Dt = class Dt { }); } else { const t = arguments.length <= 0 ? void 0 : arguments[0], e = arguments.length <= 1 ? void 0 : arguments[1], n = !!(!(arguments.length <= 2) && arguments[2]); - this.imports[t] != null && !n && sn.warn(`Overwriting ${t} with`, e), this.imports[t] = e, (t.startsWith("blots/") || t.startsWith("formats/")) && e && typeof e != "boolean" && e.blotName !== "abstract" && wr.register(e), typeof e.register == "function" && e.register(wr); + this.imports[t] != null && !n && Je.warn(`Overwriting ${t} with`, e), this.imports[t] = e, (t.startsWith("blots/") || t.startsWith("formats/")) && e && typeof e != "boolean" && e.blotName !== "abstract" && fr.register(e), typeof e.register == "function" && e.register(fr); } } constructor(t) { let e = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : {}; - if (this.options = Jf(t, e), this.container = this.options.container, this.container == null) { - sn.error("Invalid Quill container", t); + if (this.options = Rf(t, e), this.container = this.options.container, this.container == null) { + Je.error("Invalid Quill container", t); return; } - this.options.debug && Dt.debug(this.options.debug); + this.options.debug && A.debug(this.options.debug); const n = this.container.innerHTML.trim(); - this.container.classList.add("ql-container"), this.container.innerHTML = "", ei.set(this.container, this), this.root = this.addContainer("ql-editor"), this.root.classList.add("ql-blank"), this.emitter = new R(); - const s = vi.blotName, i = this.options.registry.query(s); + this.container.classList.add("ql-container"), this.container.innerHTML = "", Gs.set(this.container, this), this.root = this.addContainer("ql-editor"), this.root.classList.add("ql-blank"), this.emitter = new R(); + const s = ri.blotName, i = this.options.registry.query(s); if (!i || !("blotName" in i)) throw new Error(`Cannot initialize Quill without "${s}" blot`); if (this.scroll = new i(this.options.registry, this.root, { emitter: this.emitter - }), this.editor = new Hf(this.scroll), this.selection = new Uf(this.scroll, this.emitter), this.composition = new Gf(this.scroll, this.emitter), this.theme = new this.options.theme(this, this.options), this.keyboard = this.theme.addModule("keyboard"), this.clipboard = this.theme.addModule("clipboard"), this.history = this.theme.addModule("history"), this.uploader = this.theme.addModule("uploader"), this.theme.addModule("input"), this.theme.addModule("uiNode"), this.theme.init(), this.emitter.on(R.events.EDITOR_CHANGE, (o) => { + }), this.editor = new wf(this.scroll), this.selection = new Ef(this.scroll, this.emitter), this.composition = new xf(this.scroll, this.emitter), this.theme = new this.options.theme(this, this.options), this.keyboard = this.theme.addModule("keyboard"), this.clipboard = this.theme.addModule("clipboard"), this.history = this.theme.addModule("history"), this.uploader = this.theme.addModule("uploader"), this.theme.addModule("input"), this.theme.addModule("uiNode"), this.theme.init(), this.emitter.on(R.events.EDITOR_CHANGE, (o) => { o === R.events.TEXT_CHANGE && this.root.classList.toggle("ql-blank", this.editor.isBlank()); }), this.emitter.on(R.events.SCROLL_UPDATE, (o, a) => { const u = this.selection.lastRange, [h] = this.selection.getRange(), p = u && h ? { oldRange: u, newRange: h } : void 0; - Mt.call(this, () => this.editor.update(null, a, p), o); + Bt.call(this, () => this.editor.update(null, a, p), o); }), this.emitter.on(R.events.SCROLL_EMBED_UPDATE, (o, a) => { const u = this.selection.lastRange, [h] = this.selection.getRange(), p = u && h ? { oldRange: u, newRange: h } : void 0; - Mt.call(this, () => { - const v = new B().retain(o.offset(this)).retain({ + Bt.call(this, () => { + const v = new k().retain(o.offset(this)).retain({ [o.statics.blotName]: a }); return this.editor.update(v, [], p); - }, Dt.sources.USER); + }, A.sources.USER); }), n) { const o = this.clipboard.convert({ html: `${n}


    `, @@ -4558,7 +4567,7 @@ const Dt = class Dt { this.selection.setRange(null); } deleteText(t, e, n) { - return [t, e, , n] = le(t, e, n), Mt.call(this, () => this.editor.deleteText(t, e), n, t, -1 * e); + return [t, e, , n] = ee(t, e, n), Bt.call(this, () => this.editor.deleteText(t, e), n, t, -1 * e); } disable() { this.enable(!1); @@ -4578,11 +4587,11 @@ const Dt = class Dt { } format(t, e) { let n = arguments.length > 2 && arguments[2] !== void 0 ? arguments[2] : R.sources.API; - return Mt.call(this, () => { + return Bt.call(this, () => { const s = this.getSelection(!0); - let i = new B(); + let i = new k(); if (s == null) return i; - if (this.scroll.query(t, D.BLOCK)) + if (this.scroll.query(t, M.BLOCK)) i = this.editor.formatLine(s.index, s.length, { [t]: e }); @@ -4598,25 +4607,25 @@ const Dt = class Dt { } formatLine(t, e, n, s, i) { let o; - return [t, e, o, i] = le( + return [t, e, o, i] = ee( t, e, // @ts-expect-error n, s, i - ), Mt.call(this, () => this.editor.formatLine(t, e, o), i, t, 0); + ), Bt.call(this, () => this.editor.formatLine(t, e, o), i, t, 0); } formatText(t, e, n, s, i) { let o; - return [t, e, o, i] = le( + return [t, e, o, i] = ee( // @ts-expect-error t, e, n, s, i - ), Mt.call(this, () => this.editor.formatText(t, e, o), i, t, 0); + ), Bt.call(this, () => this.editor.formatText(t, e, o), i, t, 0); } getBounds(t) { let e = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : 0, n = null; @@ -4633,7 +4642,7 @@ const Dt = class Dt { } getContents() { let t = arguments.length > 0 && arguments[0] !== void 0 ? arguments[0] : 0, e = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : this.getLength() - t; - return [t, e] = le(t, e), this.editor.getContents(t, e); + return [t, e] = ee(t, e), this.editor.getContents(t, e); } getFormat() { let t = arguments.length > 0 && arguments[0] !== void 0 ? arguments[0] : this.getSelection(!0), e = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : 0; @@ -4659,26 +4668,26 @@ const Dt = class Dt { return this.theme.modules[t]; } getSelection() { - return (arguments.length > 0 && arguments[0] !== void 0 ? arguments[0] : !1) && this.focus(), this.update(), this.selection.getRange()[0]; + return arguments.length > 0 && arguments[0] !== void 0 && arguments[0] && this.focus(), this.update(), this.selection.getRange()[0]; } getSemanticHTML() { let t = arguments.length > 0 && arguments[0] !== void 0 ? arguments[0] : 0, e = arguments.length > 1 ? arguments[1] : void 0; - return typeof t == "number" && (e = e ?? this.getLength() - t), [t, e] = le(t, e), this.editor.getHTML(t, e); + return typeof t == "number" && (e = e ?? this.getLength() - t), [t, e] = ee(t, e), this.editor.getHTML(t, e); } getText() { let t = arguments.length > 0 && arguments[0] !== void 0 ? arguments[0] : 0, e = arguments.length > 1 ? arguments[1] : void 0; - return typeof t == "number" && (e = e ?? this.getLength() - t), [t, e] = le(t, e), this.editor.getText(t, e); + return typeof t == "number" && (e = e ?? this.getLength() - t), [t, e] = ee(t, e), this.editor.getText(t, e); } hasFocus() { return this.selection.hasFocus(); } insertEmbed(t, e, n) { - let s = arguments.length > 3 && arguments[3] !== void 0 ? arguments[3] : Dt.sources.API; - return Mt.call(this, () => this.editor.insertEmbed(t, e, n), s, t); + let s = arguments.length > 3 && arguments[3] !== void 0 ? arguments[3] : A.sources.API; + return Bt.call(this, () => this.editor.insertEmbed(t, e, n), s, t); } insertText(t, e, n, s, i) { let o; - return [t, , o, i] = le(t, 0, n, s, i), Mt.call(this, () => this.editor.insertText(t, e, o), i, t, e.length); + return [t, , o, i] = ee(t, 0, n, s, i), Bt.call(this, () => this.editor.insertText(t, e, o), i, t, e.length); } isEnabled() { return this.scroll.isEnabled(); @@ -4693,10 +4702,10 @@ const Dt = class Dt { return this.emitter.once(...arguments); } removeFormat(t, e, n) { - return [t, e, , n] = le(t, e, n), Mt.call(this, () => this.editor.removeFormat(t, e), n, t); + return [t, e, , n] = ee(t, e, n), Bt.call(this, () => this.editor.removeFormat(t, e), n, t); } scrollRectIntoView(t) { - Zf(this.root, t); + _f(this.root, t); } /** * @deprecated Use Quill#scrollSelectionIntoView() instead. @@ -4714,18 +4723,18 @@ const Dt = class Dt { } setContents(t) { let e = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : R.sources.API; - return Mt.call(this, () => { - t = new B(t); + return Bt.call(this, () => { + t = new k(t); const n = this.getLength(), s = this.editor.deleteText(0, n), i = this.editor.insertContents(0, t), o = this.editor.deleteText(this.getLength() - 1, 1); return s.compose(i).compose(o); }, e); } setSelection(t, e, n) { - t == null ? this.selection.setRange(null, e || Dt.sources.API) : ([t, e, , n] = le(t, e, n), this.selection.setRange(new Ie(Math.max(0, t), e), n), n !== R.sources.SILENT && this.scrollSelectionIntoView()); + t == null ? this.selection.setRange(null, e || A.sources.API) : ([t, e, , n] = ee(t, e, n), this.selection.setRange(new Se(Math.max(0, t), e), n), n !== R.sources.SILENT && this.scrollSelectionIntoView()); } setText(t) { let e = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : R.sources.API; - const n = new B().insert(t); + const n = new k().insert(t); return this.setContents(n, e); } update() { @@ -4735,32 +4744,13 @@ const Dt = class Dt { } updateContents(t) { let e = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : R.sources.API; - return Mt.call(this, () => (t = new B(t), this.editor.applyDelta(t)), e, !0); + return Bt.call(this, () => (t = new k(t), this.editor.applyDelta(t)), e, !0); } -}; -O(Dt, "DEFAULTS", { - bounds: null, - modules: { - clipboard: !0, - keyboard: !0, - history: !0, - uploader: !0 - }, - placeholder: "", - readOnly: !1, - registry: wr, - theme: "default" -}), O(Dt, "events", R.events), O(Dt, "sources", R.sources), O(Dt, "version", "2.0.3"), O(Dt, "imports", { - delta: B, - parchment: Cf, - "core/module": zt, - "core/theme": cn -}); -let w = Dt; -function yl(r) { +} +function rl(r) { return typeof r == "string" ? document.querySelector(r) : r; } -function $s(r) { +function qs(r) { return Object.entries(r ?? {}).reduce((t, e) => { let [n, s] = e; return { @@ -4769,87 +4759,92 @@ function $s(r) { }; }, {}); } -function vl(r) { +function sl(r) { return Object.fromEntries(Object.entries(r).filter((t) => t[1] !== void 0)); } -function Jf(r, t) { - const e = yl(r); +function Rf(r, t) { + const e = rl(r); if (!e) throw new Error("Invalid Quill container"); - const s = !t.theme || t.theme === w.DEFAULTS.theme ? cn : w.import(`themes/${t.theme}`); + const s = !t.theme || t.theme === A.DEFAULTS.theme ? rn : A.import(`themes/${t.theme}`); if (!s) throw new Error(`Invalid theme ${t.theme}. Did you register it?`); const { modules: i, ...o - } = w.DEFAULTS, { + } = A.DEFAULTS, { modules: a, ...u } = s.DEFAULTS; - let h = $s(t.modules); + let h = qs(t.modules); h != null && h.toolbar && h.toolbar.constructor !== Object && (h = { ...h, toolbar: { container: h.toolbar } }); - const p = ve({}, $s(i), $s(a), h), v = { + const p = fe({}, qs(i), qs(a), h), v = { ...o, - ...vl(u), - ...vl(t) + ...sl(u), + ...sl(t) }; let f = t.registry; - return f ? t.formats && sn.warn('Ignoring "formats" option because "registry" is specified') : f = t.formats ? Qf(t.formats, v.registry, sn) : v.registry, { + return f ? t.formats && Je.warn('Ignoring "formats" option because "registry" is specified') : f = t.formats ? Of(t.formats, v.registry, Je) : v.registry, { ...v, registry: f, container: e, theme: s, modules: Object.entries(p).reduce((m, y) => { - let [A, N] = y; + let [w, N] = y; if (!N) return m; - const x = w.import(`modules/${A}`); - return x == null ? (sn.error(`Cannot load ${A} module. Are you sure you registered it?`), m) : { + const x = A.import(`modules/${w}`); + return x == null ? (Je.error(`Cannot load ${w} module. Are you sure you registered it?`), m) : { ...m, // @ts-expect-error - [A]: ve({}, x.DEFAULTS || {}, N) + [w]: fe({}, x.DEFAULTS || {}, N) }; }, {}), - bounds: yl(v.bounds) + bounds: rl(v.bounds) }; } -function Mt(r, t, e, n) { +function Bt(r, t, e, n) { if (!this.isEnabled() && t === R.sources.USER && !this.allowReadOnlyEdits) - return new B(); + return new k(); let s = e == null ? null : this.getSelection(); const i = this.editor.delta, o = r(); - if (s != null && (e === !0 && (e = s.index), n == null ? s = El(s, o, t) : n !== 0 && (s = El(s, e, n, t)), this.setSelection(s, R.sources.SILENT)), o.length() > 0) { + if (s != null && (e === !0 && (e = s.index), n == null ? s = il(s, o, t) : n !== 0 && (s = il(s, e, n, t)), this.setSelection(s, R.sources.SILENT)), o.length() > 0) { const a = [R.events.TEXT_CHANGE, o, i, t]; this.emitter.emit(R.events.EDITOR_CHANGE, ...a), t !== R.sources.SILENT && this.emitter.emit(...a); } return o; } -function le(r, t, e, n, s) { +function ee(r, t, e, n, s) { let i = {}; return typeof r.index == "number" && typeof r.length == "number" ? typeof t != "number" ? (s = n, n = e, e = t, t = r.length, r = r.index) : (t = r.length, r = r.index) : typeof t != "number" && (s = n, n = e, e = t, t = 0), typeof e == "object" ? (i = e, s = n) : typeof e == "string" && (n != null ? i[e] = n : s = e), s = s || R.sources.API, [r, t, i, s]; } -function El(r, t, e, n) { +function il(r, t, e, n) { const s = typeof e == "number" ? e : 0; if (r == null) return null; let i, o; return t && typeof t.transformPosition == "function" ? [i, o] = [r.index, r.index + r.length].map((a) => ( // @ts-expect-error -- TODO: add a better type guard around `index` t.transformPosition(a, n !== R.sources.USER) - )) : [i, o] = [r.index, r.index + r.length].map((a) => a < t || a === t && n === R.sources.USER ? a : s >= 0 ? a + s : Math.max(t, a + s)), new Ie(i, o - i); + )) : [i, o] = [r.index, r.index + r.length].map((a) => a < t || a === t && n === R.sources.USER ? a : s >= 0 ? a + s : Math.max(t, a + s)), new Se(i, o - i); } -class Me extends Mr { +class Re extends Sr { } -function Al(r) { - return r instanceof pt || r instanceof Ct; +function ll(r) { + return r instanceof gt || r instanceof qt; } -function wl(r) { +function ol(r) { return typeof r.updateContent == "function"; } -class en extends vi { +class If extends ri { + static blotName = "scroll"; + static className = "ql-editor"; + static tagName = "DIV"; + static defaultChild = gt; + static allowedChildren = [gt, qt, Re]; constructor(t, e, n) { let { emitter: s @@ -4876,11 +4871,11 @@ class en extends vi { deleteAt(t, e) { const [n, s] = this.line(t), [i] = this.line(t + e); if (super.deleteAt(t, e), i != null && n !== i && s > 0) { - if (n instanceof Ct || i instanceof Ct) { + if (n instanceof qt || i instanceof qt) { this.optimize(); return; } - const o = i.children.head instanceof Ht ? null : i.children.head; + const o = i.children.head instanceof Ft ? null : i.children.head; n.moveChildren(i, o), n.remove(); } this.optimize(); @@ -4894,7 +4889,7 @@ class en extends vi { } insertAt(t, e, n) { if (t >= this.length()) - if (n == null || this.scroll.query(e, D.BLOCK) == null) { + if (n == null || this.scroll.query(e, M.BLOCK) == null) { const s = this.scroll.create(this.statics.defaultChild.blotName); this.appendChild(s), n == null && e.endsWith(` `) ? s.insertAt(0, e.slice(0, -1), n) : s.insertAt(0, e, n); @@ -4907,27 +4902,27 @@ class en extends vi { this.optimize(); } insertBefore(t, e) { - if (t.statics.scope === D.INLINE_BLOT) { + if (t.statics.scope === M.INLINE_BLOT) { const n = this.scroll.create(this.statics.defaultChild.blotName); n.appendChild(t), super.insertBefore(n, e); } else super.insertBefore(t, e); } insertContents(t, e) { - const n = this.deltaToRenderBlocks(e.concat(new B().insert(` + const n = this.deltaToRenderBlocks(e.concat(new k().insert(` `))), s = n.pop(); if (s == null) return; this.batchStart(); const i = n.shift(); if (i) { - const u = i.type === "block" && (i.delta.length() === 0 || !this.descendant(Ct, t)[0] && t < this.length()), h = i.type === "block" ? i.delta : new B().insert({ + const u = i.type === "block" && (i.delta.length() === 0 || !this.descendant(qt, t)[0] && t < this.length()), h = i.type === "block" ? i.delta : new k().insert({ [i.key]: i.value }); - Ps(this, t, h); + Os(this, t, h); const p = i.type === "block" ? 1 : 0, v = t + h.length() + p; u && this.insertAt(v - 1, ` `); - const f = qt(this.line(t)[0]), m = It.AttributeMap.diff(f, i.attributes) || {}; + const f = _t(this.line(t)[0]), m = Ot.AttributeMap.diff(f, i.attributes) || {}; Object.keys(m).forEach((y) => { this.formatAt(v - 1, 1, y, m[y]); }), t = v; @@ -4936,7 +4931,7 @@ class en extends vi { if (n.length && (o && (o = o.split(a), a = 0), n.forEach((u) => { if (u.type === "block") { const h = this.createBlock(u.attributes, o || void 0); - Ps(h, 0, u.delta); + Os(h, 0, u.delta); } else { const h = this.create(u.key, u.value); this.insertBefore(h, o || void 0), Object.keys(u.attributes).forEach((p) => { @@ -4945,7 +4940,7 @@ class en extends vi { } })), s.type === "block" && s.delta.length()) { const u = o ? o.offset(o.scroll) + a : this.length(); - Ps(this, u, s.delta); + Os(this, u, s.delta); } this.batchEnd(), this.optimize(); } @@ -4957,17 +4952,17 @@ class en extends vi { if (!e) return [null, -1]; const [n, s] = e; - return n instanceof Et ? [n, s] : [null, -1]; + return n instanceof vt ? [n, s] : [null, -1]; } line(t) { - return t === this.length() ? this.line(t - 1) : this.descendant(Al, t); + return t === this.length() ? this.line(t - 1) : this.descendant(ll, t); } lines() { let t = arguments.length > 0 && arguments[0] !== void 0 ? arguments[0] : 0, e = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : Number.MAX_VALUE; const n = (s, i, o) => { let a = [], u = o; return s.children.forEachAt(i, o, (h, p, v) => { - Al(h) ? a.push(h) : h instanceof Mr && (a = a.concat(n(h, p, u))), u -= v; + ll(h) ? a.push(h) : h instanceof Sr && (a = a.concat(n(h, p, u))), u -= v; }), a; }; return n(this, t, e); @@ -4992,21 +4987,21 @@ class en extends vi { target: s } = n; const i = this.find(s, !0); - return i && !wl(i); + return i && !ol(i); }), t.length > 0 && this.emitter.emit(R.events.SCROLL_BEFORE_UPDATE, e, t), super.update(t.concat([])), t.length > 0 && this.emitter.emit(R.events.SCROLL_UPDATE, e, t); } updateEmbedAt(t, e, n) { - const [s] = this.descendant((i) => i instanceof Ct, t); - s && s.statics.blotName === e && wl(s) && s.updateContent(n); + const [s] = this.descendant((i) => i instanceof qt, t); + s && s.statics.blotName === e && ol(s) && s.updateContent(n); } handleDragStart(t) { t.preventDefault(); } deltaToRenderBlocks(t) { const e = []; - let n = new B(); + let n = new k(); return t.forEach((s) => { - const i = s == null ? void 0 : s.insert; + const i = s?.insert; if (i) if (typeof i == "string") { const o = i.split(` @@ -5016,18 +5011,18 @@ class en extends vi { type: "block", delta: n, attributes: s.attributes ?? {} - }), n = new B(); + }), n = new k(); }); const a = o[o.length - 1]; a && n.insert(a, s.attributes); } else { const o = Object.keys(i)[0]; if (!o) return; - this.query(o, D.INLINE) ? n.push(s) : (n.length() && e.push({ + this.query(o, M.INLINE) ? n.push(s) : (n.length() && e.push({ type: "block", delta: n, attributes: {} - }), n = new B(), e.push({ + }), n = new k(), e.push({ type: "blockEmbed", key: o, value: i[o], @@ -5045,7 +5040,7 @@ class en extends vi { const s = {}; Object.entries(t).forEach((a) => { let [u, h] = a; - this.query(u, D.BLOCK & D.BLOT) != null ? n = u : s[u] = h; + this.query(u, M.BLOCK & M.BLOT) != null ? n = u : s[u] = h; }); const i = this.create(n || this.statics.defaultChild.blotName, n ? t[n] : void 0); this.insertBefore(i, e || void 0); @@ -5056,23 +5051,22 @@ class en extends vi { }), i; } } -O(en, "blotName", "scroll"), O(en, "className", "ql-editor"), O(en, "tagName", "DIV"), O(en, "defaultChild", pt), O(en, "allowedChildren", [pt, Ct, Me]); -function Ps(r, t, e) { +function Os(r, t, e) { e.reduce((n, s) => { - const i = It.Op.length(s); + const i = Ot.Op.length(s); let o = s.attributes || {}; if (s.insert != null) { if (typeof s.insert == "string") { const a = s.insert; r.insertAt(n, a); - const [u] = r.descendant(Et, n), h = qt(u); - o = It.AttributeMap.diff(h, o) || {}; + const [u] = r.descendant(vt, n), h = _t(u); + o = Ot.AttributeMap.diff(h, o) || {}; } else if (typeof s.insert == "object") { const a = Object.keys(s.insert)[0]; if (a == null) return n; - if (r.insertAt(n, a, s.insert[a]), r.scroll.query(a, D.INLINE) != null) { - const [h] = r.descendant(Et, n), p = qt(h); - o = It.AttributeMap.diff(p, o) || {}; + if (r.insertAt(n, a, s.insert[a]), r.scroll.query(a, M.INLINE) != null) { + const [h] = r.descendant(vt, n), p = _t(h); + o = Ot.AttributeMap.diff(p, o) || {}; } } } @@ -5081,26 +5075,26 @@ function Ps(r, t, e) { }), n + i; }, t); } -const Ai = { - scope: D.BLOCK, +const ii = { + scope: M.BLOCK, whitelist: ["right", "center", "justify"] -}, td = new Jt("align", "align", Ai), Eo = new Ft("align", "ql-align", Ai), Ao = new we("align", "text-align", Ai); -class wo extends we { +}, kf = new Wt("align", "align", ii), io = new Ut("align", "ql-align", ii), lo = new me("align", "text-align", ii); +class oo extends me { value(t) { let e = super.value(t); return e.startsWith("rgb(") ? (e = e.replace(/^[^\d]+/, "").replace(/[^\d]+$/, ""), `#${e.split(",").map((s) => `00${parseInt(s, 10).toString(16)}`.slice(-2)).join("")}`) : e; } } -const ed = new Ft("color", "ql-color", { - scope: D.INLINE -}), wi = new wo("color", "color", { - scope: D.INLINE -}), nd = new Ft("background", "ql-bg", { - scope: D.INLINE -}), Ni = new wo("background", "background-color", { - scope: D.INLINE +const Bf = new Ut("color", "ql-color", { + scope: M.INLINE +}), li = new oo("color", "color", { + scope: M.INLINE +}), Mf = new Ut("background", "ql-bg", { + scope: M.INLINE +}), oi = new oo("background", "background-color", { + scope: M.INLINE }); -class De extends Me { +class Ie extends Re { static create(t) { const e = super.create(t); return e.setAttribute("spellcheck", "false"), e; @@ -5111,49 +5105,49 @@ class De extends Me { } html(t, e) { return `
    -${Dr(this.code(t, e))}
    +${_r(this.code(t, e))}
     
    `; } } -class At extends pt { +class wt extends gt { + static TAB = " "; static register() { - w.register(De); - } -} -O(At, "TAB", " "); -class Ti extends te { -} -Ti.blotName = "code"; -Ti.tagName = "CODE"; -At.blotName = "code-block"; -At.className = "ql-code-block"; -At.tagName = "DIV"; -De.blotName = "code-block-container"; -De.className = "ql-code-block-container"; -De.tagName = "DIV"; -De.allowedChildren = [At]; -At.allowedChildren = [Ut, Ht, an]; -At.requiredContainer = De; -const xi = { - scope: D.BLOCK, + A.register(Ie); + } +} +class ai extends At { +} +ai.blotName = "code"; +ai.tagName = "CODE"; +wt.blotName = "code-block"; +wt.className = "ql-code-block"; +wt.tagName = "DIV"; +Ie.blotName = "code-block-container"; +Ie.className = "ql-code-block-container"; +Ie.tagName = "DIV"; +Ie.allowedChildren = [wt]; +wt.allowedChildren = [$t, Ft, Dt]; +wt.requiredContainer = Ie; +const ci = { + scope: M.BLOCK, whitelist: ["rtl"] -}, No = new Jt("direction", "dir", xi), To = new Ft("direction", "ql-direction", xi), xo = new we("direction", "direction", xi), Lo = { - scope: D.INLINE, +}, ao = new Wt("direction", "dir", ci), co = new Ut("direction", "ql-direction", ci), uo = new me("direction", "direction", ci), ho = { + scope: M.INLINE, whitelist: ["serif", "monospace"] -}, So = new Ft("font", "ql-font", Lo); -class rd extends we { +}, fo = new Ut("font", "ql-font", ho); +class Df extends me { value(t) { return super.value(t).replace(/["']/g, ""); } } -const _o = new rd("font", "font-family", Lo), qo = new Ft("size", "ql-size", { - scope: D.INLINE, +const go = new Df("font", "font-family", ho), po = new Ut("size", "ql-size", { + scope: M.INLINE, whitelist: ["small", "large", "huge"] -}), Oo = new we("size", "font-size", { - scope: D.INLINE, +}), mo = new me("size", "font-size", { + scope: M.INLINE, whitelist: ["10px", "18px", "32px"] -}), sd = he("quill:keyboard"), id = /Mac/i.test(navigator.platform) ? "metaKey" : "ctrlKey"; -class jr extends zt { +}), jf = ie("quill:keyboard"), Pf = /Mac/i.test(navigator.platform) ? "metaKey" : "ctrlKey"; +class Cr extends Yt { static match(t, e) { return ["altKey", "ctrlKey", "metaKey", "shiftKey"].some((n) => !!e[n] !== t[n] && e[n] !== null) ? !1 : e.key === t.key || e.key === t.which; } @@ -5208,9 +5202,9 @@ class jr extends zt { } addBinding(t) { let e = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : {}, n = arguments.length > 2 && arguments[2] !== void 0 ? arguments[2] : {}; - const s = od(t); + const s = Uf(t); if (s == null) { - sd.warn("Attempted to add invalid keyboard binding", s); + jf.warn("Attempted to add invalid keyboard binding", s); return; } typeof e == "function" && (e = { @@ -5230,13 +5224,13 @@ class jr extends zt { listen() { this.quill.root.addEventListener("keydown", (t) => { if (t.defaultPrevented || t.isComposing || t.keyCode === 229 && (t.key === "Enter" || t.key === "Backspace")) return; - const s = (this.bindings[t.key] || []).concat(this.bindings[t.which] || []).filter((x) => jr.match(t, x)); + const s = (this.bindings[t.key] || []).concat(this.bindings[t.which] || []).filter((x) => Cr.match(t, x)); if (s.length === 0) return; - const i = w.find(t.target, !0); + const i = A.find(t.target, !0); if (i && i.scroll !== this.quill.scroll) return; const o = this.quill.getSelection(); if (o == null || !this.quill.hasFocus()) return; - const [a, u] = this.quill.getLine(o.index), [h, p] = this.quill.getLeaf(o.index), [v, f] = o.length === 0 ? [h, p] : this.quill.getLeaf(o.index + o.length), m = h instanceof Cr ? h.value().slice(0, p) : "", y = v instanceof Cr ? v.value().slice(f) : "", A = { + const [a, u] = this.quill.getLine(o.index), [h, p] = this.quill.getLeaf(o.index), [v, f] = o.length === 0 ? [h, p] : this.quill.getLeaf(o.index + o.length), m = h instanceof Er ? h.value().slice(0, p) : "", y = v instanceof Er ? v.value().slice(f) : "", w = { collapsed: o.length === 0, // @ts-expect-error Fix me later empty: o.length === 0 && a.length() <= 1, @@ -5248,14 +5242,14 @@ class jr extends zt { event: t }; s.some((x) => { - if (x.collapsed != null && x.collapsed !== A.collapsed || x.empty != null && x.empty !== A.empty || x.offset != null && x.offset !== A.offset) + if (x.collapsed != null && x.collapsed !== w.collapsed || x.empty != null && x.empty !== w.empty || x.offset != null && x.offset !== w.offset) return !1; if (Array.isArray(x.format)) { - if (x.format.every((C) => A.format[C] == null)) + if (x.format.every((q) => w.format[q] == null)) return !1; - } else if (typeof x.format == "object" && !Object.keys(x.format).every((C) => x.format[C] === !0 ? A.format[C] != null : x.format[C] === !1 ? A.format[C] == null : pe(x.format[C], A.format[C]))) + } else if (typeof x.format == "object" && !Object.keys(x.format).every((q) => x.format[q] === !0 ? w.format[q] != null : x.format[q] === !1 ? w.format[q] == null : ce(x.format[q], w.format[q]))) return !1; - return x.prefix != null && !x.prefix.test(A.prefix) || x.suffix != null && !x.suffix.test(A.suffix) ? !1 : x.handler.call(this, o, A, x) !== !0; + return x.prefix != null && !x.prefix.test(w.prefix) || x.suffix != null && !x.suffix.test(w.suffix) ? !1 : x.handler.call(this, o, w, x) !== !0; }) && t.preventDefault(); }); } @@ -5264,57 +5258,57 @@ class jr extends zt { if (t.index === 0 || this.quill.getLength() <= 1) return; let s = {}; const [i] = this.quill.getLine(t.index); - let o = new B().retain(t.index - n).delete(n); + let o = new k().retain(t.index - n).delete(n); if (e.offset === 0) { const [a] = this.quill.getLine(t.index - 1); if (a && !(a.statics.blotName === "block" && a.length() <= 1)) { const h = i.formats(), p = this.quill.getFormat(t.index - 1, 1); - if (s = It.AttributeMap.diff(h, p) || {}, Object.keys(s).length > 0) { - const v = new B().retain(t.index + i.length() - 2).retain(1, s); + if (s = Ot.AttributeMap.diff(h, p) || {}, Object.keys(s).length > 0) { + const v = new k().retain(t.index + i.length() - 2).retain(1, s); o = o.compose(v); } } } - this.quill.updateContents(o, w.sources.USER), this.quill.focus(); + this.quill.updateContents(o, A.sources.USER), this.quill.focus(); } handleDelete(t, e) { const n = /^[\uD800-\uDBFF][\uDC00-\uDFFF]/.test(e.suffix) ? 2 : 1; if (t.index >= this.quill.getLength() - n) return; let s = {}; const [i] = this.quill.getLine(t.index); - let o = new B().retain(t.index).delete(n); + let o = new k().retain(t.index).delete(n); if (e.offset >= i.length() - 1) { const [a] = this.quill.getLine(t.index + 1); if (a) { const u = i.formats(), h = this.quill.getFormat(t.index, 1); - s = It.AttributeMap.diff(u, h) || {}, Object.keys(s).length > 0 && (o = o.retain(a.length() - 1).retain(1, s)); + s = Ot.AttributeMap.diff(u, h) || {}, Object.keys(s).length > 0 && (o = o.retain(a.length() - 1).retain(1, s)); } } - this.quill.updateContents(o, w.sources.USER), this.quill.focus(); + this.quill.updateContents(o, A.sources.USER), this.quill.focus(); } handleDeleteRange(t) { - Li({ + ui({ range: t, quill: this.quill }), this.quill.focus(); } handleEnter(t, e) { - const n = Object.keys(e.format).reduce((i, o) => (this.quill.scroll.query(o, D.BLOCK) && !Array.isArray(e.format[o]) && (i[o] = e.format[o]), i), {}), s = new B().retain(t.index).delete(t.length).insert(` + const n = Object.keys(e.format).reduce((i, o) => (this.quill.scroll.query(o, M.BLOCK) && !Array.isArray(e.format[o]) && (i[o] = e.format[o]), i), {}), s = new k().retain(t.index).delete(t.length).insert(` `, n); - this.quill.updateContents(s, w.sources.USER), this.quill.setSelection(t.index + 1, w.sources.SILENT), this.quill.focus(); + this.quill.updateContents(s, A.sources.USER), this.quill.setSelection(t.index + 1, A.sources.SILENT), this.quill.focus(); } } -const ld = { +const $f = { bindings: { - bold: Us("bold"), - italic: Us("italic"), - underline: Us("underline"), + bold: Rs("bold"), + italic: Rs("italic"), + underline: Rs("underline"), indent: { // highlight tab or tab at beginning of list, indent or blockquote key: "Tab", format: ["blockquote", "indent", "list"], handler(r, t) { - return t.collapsed && t.offset !== 0 ? !0 : (this.quill.format("indent", "+1", w.sources.USER), !1); + return t.collapsed && t.offset !== 0 ? !0 : (this.quill.format("indent", "+1", A.sources.USER), !1); } }, outdent: { @@ -5323,7 +5317,7 @@ const ld = { format: ["blockquote", "indent", "list"], // highlight tab or tab at beginning of list, indent or blockquote handler(r, t) { - return t.collapsed && t.offset !== 0 ? !0 : (this.quill.format("indent", "-1", w.sources.USER), !1); + return t.collapsed && t.offset !== 0 ? !0 : (this.quill.format("indent", "-1", A.sources.USER), !1); } }, "outdent backspace": { @@ -5336,18 +5330,18 @@ const ld = { format: ["indent", "list"], offset: 0, handler(r, t) { - t.format.indent != null ? this.quill.format("indent", "-1", w.sources.USER) : t.format.list != null && this.quill.format("list", !1, w.sources.USER); + t.format.indent != null ? this.quill.format("indent", "-1", A.sources.USER) : t.format.list != null && this.quill.format("list", !1, A.sources.USER); } }, - "indent code-block": Nl(!0), - "outdent code-block": Nl(!1), + "indent code-block": al(!0), + "outdent code-block": al(!1), "remove tab": { key: "Tab", shiftKey: !0, collapsed: !0, prefix: /\t$/, handler(r) { - this.quill.deleteText(r.index - 1, 1, w.sources.USER); + this.quill.deleteText(r.index - 1, 1, A.sources.USER); } }, tab: { @@ -5355,8 +5349,8 @@ const ld = { handler(r, t) { if (t.format.table) return !0; this.quill.history.cutoff(); - const e = new B().retain(r.index).delete(r.length).insert(" "); - return this.quill.updateContents(e, w.sources.USER), this.quill.history.cutoff(), this.quill.setSelection(r.index + 1, w.sources.SILENT), !1; + const e = new k().retain(r.index).delete(r.length).insert(" "); + return this.quill.updateContents(e, A.sources.USER), this.quill.history.cutoff(), this.quill.setSelection(r.index + 1, A.sources.SILENT), !1; } }, "blockquote empty enter": { @@ -5365,7 +5359,7 @@ const ld = { format: ["blockquote"], empty: !0, handler() { - this.quill.format("blockquote", !1, w.sources.USER); + this.quill.format("blockquote", !1, A.sources.USER); } }, "list empty enter": { @@ -5377,7 +5371,7 @@ const ld = { const e = { list: !1 }; - t.format.indent && (e.indent = !1), this.quill.formatLine(r.index, r.length, e, w.sources.USER); + t.format.indent && (e.indent = !1), this.quill.formatLine(r.index, r.length, e, A.sources.USER); } }, "checklist enter": { @@ -5391,11 +5385,11 @@ const ld = { // @ts-expect-error Fix me later ...t.formats(), list: "checked" - }, s = new B().retain(r.index).insert(` + }, s = new k().retain(r.index).insert(` `, n).retain(t.length() - e - 1).retain(1, { list: "unchecked" }); - this.quill.updateContents(s, w.sources.USER), this.quill.setSelection(r.index + 1, w.sources.SILENT), this.quill.scrollSelectionIntoView(); + this.quill.updateContents(s, A.sources.USER), this.quill.setSelection(r.index + 1, A.sources.SILENT), this.quill.scrollSelectionIntoView(); } }, "header enter": { @@ -5404,11 +5398,11 @@ const ld = { format: ["header"], suffix: /^$/, handler(r, t) { - const [e, n] = this.quill.getLine(r.index), s = new B().retain(r.index).insert(` + const [e, n] = this.quill.getLine(r.index), s = new k().retain(r.index).insert(` `, t.format).retain(e.length() - n - 1).retain(1, { header: null }); - this.quill.updateContents(s, w.sources.USER), this.quill.setSelection(r.index + 1, w.sources.SILENT), this.quill.scrollSelectionIntoView(); + this.quill.updateContents(s, A.sources.USER), this.quill.setSelection(r.index + 1, A.sources.SILENT), this.quill.scrollSelectionIntoView(); } }, "table backspace": { @@ -5434,18 +5428,18 @@ const ld = { handler(r) { const t = this.quill.getModule("table"); if (t) { - const [e, n, s, i] = t.getTable(r), o = ad(e, n, s, i); + const [e, n, s, i] = t.getTable(r), o = Ff(e, n, s, i); if (o == null) return; let a = e.offset(); if (o < 0) { - const u = new B().retain(a).insert(` + const u = new k().retain(a).insert(` `); - this.quill.updateContents(u, w.sources.USER), this.quill.setSelection(r.index + 1, r.length, w.sources.SILENT); + this.quill.updateContents(u, A.sources.USER), this.quill.setSelection(r.index + 1, r.length, A.sources.SILENT); } else if (o > 0) { a += e.length(); - const u = new B().retain(a).insert(` + const u = new k().retain(a).insert(` `); - this.quill.updateContents(u, w.sources.USER), this.quill.setSelection(a, w.sources.USER); + this.quill.updateContents(u, A.sources.USER), this.quill.setSelection(a, A.sources.USER); } } } @@ -5459,7 +5453,7 @@ const ld = { event: e, line: n } = t, s = n.offset(this.quill.scroll); - e.shiftKey ? this.quill.setSelection(s - 1, w.sources.USER) : this.quill.setSelection(s + n.length(), w.sources.USER); + e.shiftKey ? this.quill.setSelection(s - 1, A.sources.USER) : this.quill.setSelection(s + n.length(), A.sources.USER); } }, "list autofill": { @@ -5494,11 +5488,11 @@ const ld = { default: i = "ordered"; } - this.quill.insertText(r.index, " ", w.sources.USER), this.quill.history.cutoff(); - const o = new B().retain(r.index - s).delete(e + 1).retain(n.length() - 2 - s).retain(1, { + this.quill.insertText(r.index, " ", A.sources.USER), this.quill.history.cutoff(); + const o = new k().retain(r.index - s).delete(e + 1).retain(n.length() - 2 - s).retain(1, { list: i }); - return this.quill.updateContents(o, w.sources.USER), this.quill.history.cutoff(), this.quill.setSelection(r.index - e, w.sources.SILENT), !1; + return this.quill.updateContents(o, A.sources.USER), this.quill.history.cutoff(), this.quill.setSelection(r.index - e, A.sources.SILENT), !1; } }, "code exit": { @@ -5512,24 +5506,24 @@ const ld = { let n = 2, s = t; for (; s != null && s.length() <= 1 && s.formats()["code-block"]; ) if (s = s.prev, n -= 1, n <= 0) { - const i = new B().retain(r.index + t.length() - e - 2).retain(1, { + const i = new k().retain(r.index + t.length() - e - 2).retain(1, { "code-block": null }).delete(1); - return this.quill.updateContents(i, w.sources.USER), this.quill.setSelection(r.index - 1, w.sources.SILENT), !1; + return this.quill.updateContents(i, A.sources.USER), this.quill.setSelection(r.index - 1, A.sources.SILENT), !1; } return !0; } }, - "embed left": Nr("ArrowLeft", !1), - "embed left shift": Nr("ArrowLeft", !0), - "embed right": Nr("ArrowRight", !1), - "embed right shift": Nr("ArrowRight", !0), - "table down": Tl(!1), - "table up": Tl(!0) + "embed left": dr("ArrowLeft", !1), + "embed left shift": dr("ArrowLeft", !0), + "embed right": dr("ArrowRight", !1), + "embed right shift": dr("ArrowRight", !0), + "table down": cl(!1), + "table up": cl(!0) } }; -jr.DEFAULTS = ld; -function Nl(r) { +Cr.DEFAULTS = $f; +function al(r) { return { key: "Tab", shiftKey: !r, @@ -5544,7 +5538,7 @@ function Nl(r) { TAB: i } = s; if (t.length === 0 && !n.shiftKey) { - this.quill.insertText(t.index, i, w.sources.USER), this.quill.setSelection(t.index + i.length, w.sources.SILENT); + this.quill.insertText(t.index, i, A.sources.USER), this.quill.setSelection(t.index + i.length, A.sources.SILENT); return; } const o = t.length === 0 ? this.quill.getLines(t.index, 1) : this.quill.getLines(t); @@ -5554,11 +5548,11 @@ function Nl(r) { } = t; o.forEach((h, p) => { r ? (h.insertAt(0, i), p === 0 ? a += i.length : u += i.length) : h.domNode.textContent.startsWith(i) && (h.deleteAt(0, i.length), p === 0 ? a -= i.length : u -= i.length); - }), this.quill.update(w.sources.USER), this.quill.setSelection(a, u, w.sources.SILENT); + }), this.quill.update(A.sources.USER), this.quill.setSelection(a, u, A.sources.SILENT); } }; } -function Nr(r, t) { +function dr(r, t) { return { key: r, shiftKey: t, @@ -5570,20 +5564,20 @@ function Nr(r, t) { } = n; r === "ArrowRight" && (s += n.length + 1); const [i] = this.quill.getLeaf(s); - return i instanceof Lt ? (r === "ArrowLeft" ? t ? this.quill.setSelection(n.index - 1, n.length + 1, w.sources.USER) : this.quill.setSelection(n.index - 1, w.sources.USER) : t ? this.quill.setSelection(n.index, n.length + 1, w.sources.USER) : this.quill.setSelection(n.index + n.length + 1, w.sources.USER), !1) : !0; + return i instanceof Lt ? (r === "ArrowLeft" ? t ? this.quill.setSelection(n.index - 1, n.length + 1, A.sources.USER) : this.quill.setSelection(n.index - 1, A.sources.USER) : t ? this.quill.setSelection(n.index, n.length + 1, A.sources.USER) : this.quill.setSelection(n.index + n.length + 1, A.sources.USER), !1) : !0; } }; } -function Us(r) { +function Rs(r) { return { key: r[0], shortKey: !0, handler(t, e) { - this.quill.format(r, !e.format[r], w.sources.USER); + this.quill.format(r, !e.format[r], A.sources.USER); } }; } -function Tl(r) { +function cl(r) { return { key: r ? "ArrowUp" : "ArrowDown", collapsed: !0, @@ -5596,28 +5590,28 @@ function Tl(r) { for (; a.prev != null; ) a = a.prev, o = o.next; const u = o.offset(this.quill.scroll) + Math.min(e.offset, o.length() - 1); - this.quill.setSelection(u, 0, w.sources.USER); + this.quill.setSelection(u, 0, A.sources.USER); } } else { const o = s.table()[n]; - o != null && (r ? this.quill.setSelection(o.offset(this.quill.scroll) + o.length() - 1, 0, w.sources.USER) : this.quill.setSelection(o.offset(this.quill.scroll), 0, w.sources.USER)); + o != null && (r ? this.quill.setSelection(o.offset(this.quill.scroll) + o.length() - 1, 0, A.sources.USER) : this.quill.setSelection(o.offset(this.quill.scroll), 0, A.sources.USER)); } return !1; } }; } -function od(r) { +function Uf(r) { if (typeof r == "string" || typeof r == "number") r = { key: r }; else if (typeof r == "object") - r = nn(r); + r = Xe(r); else return null; - return r.shortKey && (r[id] = r.shortKey, delete r.shortKey), r; + return r.shortKey && (r[Pf] = r.shortKey, delete r.shortKey), r; } -function Li(r) { +function ui(r) { let { quill: t, range: e @@ -5626,85 +5620,79 @@ function Li(r) { let s = {}; if (n.length > 1) { const i = n[0].formats(), o = n[n.length - 1].formats(); - s = It.AttributeMap.diff(o, i) || {}; + s = Ot.AttributeMap.diff(o, i) || {}; } - t.deleteText(e, w.sources.USER), Object.keys(s).length > 0 && t.formatLine(e.index, 1, s, w.sources.USER), t.setSelection(e.index, w.sources.SILENT); + t.deleteText(e, A.sources.USER), Object.keys(s).length > 0 && t.formatLine(e.index, 1, s, A.sources.USER), t.setSelection(e.index, A.sources.SILENT); } -function ad(r, t, e, n) { +function Ff(r, t, e, n) { return t.prev == null && t.next == null ? e.prev == null && e.next == null ? n === 0 ? -1 : 1 : e.prev == null ? -1 : 1 : t.prev == null ? -1 : t.next == null ? 1 : null; } -const cd = /font-weight:\s*normal/, ud = ["P", "OL", "UL"], xl = (r) => r && ud.includes(r.tagName), hd = (r) => { - Array.from(r.querySelectorAll("br")).filter((t) => xl(t.previousElementSibling) && xl(t.nextElementSibling)).forEach((t) => { - var e; - (e = t.parentNode) == null || e.removeChild(t); +const Hf = /font-weight:\s*normal/, zf = ["P", "OL", "UL"], ul = (r) => r && zf.includes(r.tagName), Kf = (r) => { + Array.from(r.querySelectorAll("br")).filter((t) => ul(t.previousElementSibling) && ul(t.nextElementSibling)).forEach((t) => { + t.parentNode?.removeChild(t); }); -}, fd = (r) => { - Array.from(r.querySelectorAll('b[style*="font-weight"]')).filter((t) => { - var e; - return (e = t.getAttribute("style")) == null ? void 0 : e.match(cd); - }).forEach((t) => { - var n; +}, Gf = (r) => { + Array.from(r.querySelectorAll('b[style*="font-weight"]')).filter((t) => t.getAttribute("style")?.match(Hf)).forEach((t) => { const e = r.createDocumentFragment(); - e.append(...t.childNodes), (n = t.parentNode) == null || n.replaceChild(e, t); + e.append(...t.childNodes), t.parentNode?.replaceChild(e, t); }); }; -function dd(r) { - r.querySelector('[id^="docs-internal-guid-"]') && (fd(r), hd(r)); +function Vf(r) { + r.querySelector('[id^="docs-internal-guid-"]') && (Gf(r), Kf(r)); } -const gd = /\bmso-list:[^;]*ignore/i, pd = /\bmso-list:[^;]*\bl(\d+)/i, md = /\bmso-list:[^;]*\blevel(\d+)/i, bd = (r, t) => { - const e = r.getAttribute("style"), n = e == null ? void 0 : e.match(pd); +const Wf = /\bmso-list:[^;]*ignore/i, Zf = /\bmso-list:[^;]*\bl(\d+)/i, Yf = /\bmso-list:[^;]*\blevel(\d+)/i, Xf = (r, t) => { + const e = r.getAttribute("style"), n = e?.match(Zf); if (!n) return null; - const s = Number(n[1]), i = e == null ? void 0 : e.match(md), o = i ? Number(i[1]) : 1, a = new RegExp(`@list l${s}:level${o}\\s*\\{[^\\}]*mso-level-number-format:\\s*([\\w-]+)`, "i"), u = t.match(a), h = u && u[1] === "bullet" ? "bullet" : "ordered"; + const s = Number(n[1]), i = e?.match(Yf), o = i ? Number(i[1]) : 1, a = new RegExp(`@list l${s}:level${o}\\s*\\{[^\\}]*mso-level-number-format:\\s*([\\w-]+)`, "i"), u = t.match(a), h = u && u[1] === "bullet" ? "bullet" : "ordered"; return { id: s, indent: o, type: h, element: r }; -}, yd = (r) => { - var o, a; +}, Qf = (r) => { const t = Array.from(r.querySelectorAll("[style*=mso-list]")), e = [], n = []; - t.forEach((u) => { - (u.getAttribute("style") || "").match(gd) ? e.push(u) : n.push(u); - }), e.forEach((u) => { - var h; - return (h = u.parentNode) == null ? void 0 : h.removeChild(u); - }); - const s = r.documentElement.innerHTML, i = n.map((u) => bd(u, s)).filter((u) => u); + t.forEach((o) => { + (o.getAttribute("style") || "").match(Wf) ? e.push(o) : n.push(o); + }), e.forEach((o) => o.parentNode?.removeChild(o)); + const s = r.documentElement.innerHTML, i = n.map((o) => Xf(o, s)).filter((o) => o); for (; i.length; ) { - const u = []; - let h = i.shift(); - for (; h; ) - u.push(h), h = i.length && ((o = i[0]) == null ? void 0 : o.element) === h.element.nextElementSibling && // Different id means the next item doesn't belong to this group. - i[0].id === h.id ? i.shift() : null; - const p = document.createElement("ul"); - u.forEach((m) => { - const y = document.createElement("li"); - y.setAttribute("data-list", m.type), m.indent > 1 && y.setAttribute("class", `ql-indent-${m.indent - 1}`), y.innerHTML = m.element.innerHTML, p.appendChild(y); + const o = []; + let a = i.shift(); + for (; a; ) + o.push(a), a = i.length && i[0]?.element === a.element.nextElementSibling && // Different id means the next item doesn't belong to this group. + i[0].id === a.id ? i.shift() : null; + const u = document.createElement("ul"); + o.forEach((v) => { + const f = document.createElement("li"); + f.setAttribute("data-list", v.type), v.indent > 1 && f.setAttribute("class", `ql-indent-${v.indent - 1}`), f.innerHTML = v.element.innerHTML, u.appendChild(f); }); - const v = (a = u[0]) == null ? void 0 : a.element, { - parentNode: f - } = v ?? {}; - v && (f == null || f.replaceChild(p, v)), u.slice(1).forEach((m) => { + const h = o[0]?.element, { + parentNode: p + } = h ?? {}; + h && p?.replaceChild(u, h), o.slice(1).forEach((v) => { let { - element: y - } = m; - f == null || f.removeChild(y); + element: f + } = v; + p?.removeChild(f); }); } }; -function vd(r) { - r.documentElement.getAttribute("xmlns:w") === "urn:schemas-microsoft-com:office:word" && yd(r); +function Jf(r) { + r.documentElement.getAttribute("xmlns:w") === "urn:schemas-microsoft-com:office:word" && Qf(r); } -const Ed = [vd, dd], Ad = (r) => { - r.documentElement && Ed.forEach((t) => { +const td = [Jf, Vf], ed = (r) => { + r.documentElement && td.forEach((t) => { t(r); }); -}, wd = he("quill:clipboard"), Nd = [[Node.TEXT_NODE, Bd], [Node.TEXT_NODE, Sl], ["br", _d], [Node.ELEMENT_NODE, Sl], [Node.ELEMENT_NODE, Sd], [Node.ELEMENT_NODE, Ld], [Node.ELEMENT_NODE, Rd], ["li", Cd], ["ol, ul", Id], ["pre", qd], ["tr", kd], ["b", Fs("bold")], ["i", Fs("italic")], ["strike", Fs("strike")], ["style", Od]], Td = [td, No].reduce((r, t) => (r[t.keyName] = t, r), {}), Ll = [Ao, Ni, wi, xo, _o, Oo].reduce((r, t) => (r[t.keyName] = t, r), {}); -class Co extends zt { +}, nd = ie("quill:clipboard"), rd = [[Node.TEXT_NODE, md], [Node.TEXT_NODE, fl], ["br", cd], [Node.ELEMENT_NODE, fl], [Node.ELEMENT_NODE, ad], [Node.ELEMENT_NODE, od], [Node.ELEMENT_NODE, gd], ["li", fd], ["ol, ul", dd], ["pre", ud], ["tr", pd], ["b", Is("bold")], ["i", Is("italic")], ["strike", Is("strike")], ["style", hd]], sd = [kf, ao].reduce((r, t) => (r[t.keyName] = t, r), {}), hl = [lo, oi, li, uo, go, mo].reduce((r, t) => (r[t.keyName] = t, r), {}); +class id extends Yt { + static DEFAULTS = { + matchers: [] + }; constructor(t, e) { - super(t, e), this.quill.root.addEventListener("copy", (n) => this.onCaptureCopy(n, !1)), this.quill.root.addEventListener("cut", (n) => this.onCaptureCopy(n, !0)), this.quill.root.addEventListener("paste", this.onCapturePaste.bind(this)), this.matchers = [], Nd.concat(this.options.matchers ?? []).forEach((n) => { + super(t, e), this.quill.root.addEventListener("copy", (n) => this.onCaptureCopy(n, !1)), this.quill.root.addEventListener("cut", (n) => this.onCaptureCopy(n, !0)), this.quill.root.addEventListener("paste", this.onCapturePaste.bind(this)), this.matchers = [], rd.concat(this.options.matchers ?? []).forEach((n) => { let [s, i] = n; this.addMatcher(s, i); }); @@ -5717,43 +5705,42 @@ class Co extends zt { html: e, text: n } = t, s = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : {}; - if (s[At.blotName]) - return new B().insert(n || "", { - [At.blotName]: s[At.blotName] + if (s[wt.blotName]) + return new k().insert(n || "", { + [wt.blotName]: s[wt.blotName] }); if (!e) - return new B().insert(n || "", s); + return new k().insert(n || "", s); const i = this.convertHTML(e); - return Gn(i, ` -`) && (i.ops[i.ops.length - 1].attributes == null || s.table) ? i.compose(new B().retain(i.length() - 1).delete(1)) : i; + return Mn(i, ` +`) && (i.ops[i.ops.length - 1].attributes == null || s.table) ? i.compose(new k().retain(i.length() - 1).delete(1)) : i; } normalizeHTML(t) { - Ad(t); + ed(t); } convertHTML(t) { const e = new DOMParser().parseFromString(t, "text/html"); this.normalizeHTML(e); const n = e.body, s = /* @__PURE__ */ new WeakMap(), [i, o] = this.prepareMatching(n, s); - return Si(this.quill.scroll, n, i, o, s); + return hi(this.quill.scroll, n, i, o, s); } dangerouslyPasteHTML(t, e) { - let n = arguments.length > 2 && arguments[2] !== void 0 ? arguments[2] : w.sources.API; + let n = arguments.length > 2 && arguments[2] !== void 0 ? arguments[2] : A.sources.API; if (typeof t == "string") { const s = this.convert({ html: t, text: "" }); - this.quill.setContents(s, e), this.quill.setSelection(0, w.sources.SILENT); + this.quill.setContents(s, e), this.quill.setSelection(0, A.sources.SILENT); } else { const s = this.convert({ html: e, text: "" }); - this.quill.updateContents(new B().retain(t).concat(s), n), this.quill.setSelection(t + s.length(), w.sources.SILENT); + this.quill.updateContents(new k().retain(t).concat(s), n), this.quill.setSelection(t + s.length(), A.sources.SILENT); } } onCaptureCopy(t) { - var o, a; let e = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : !1; if (t.defaultPrevented) return; t.preventDefault(); @@ -5763,7 +5750,7 @@ class Co extends zt { html: s, text: i } = this.onCopy(n, e); - (o = t.clipboardData) == null || o.setData("text/plain", i), (a = t.clipboardData) == null || a.setData("text/html", s), e && Li({ + t.clipboardData?.setData("text/plain", i), t.clipboardData?.setData("text/html", s), e && ui({ range: n, quill: this.quill }); @@ -5776,25 +5763,24 @@ class Co extends zt { `); } onCapturePaste(t) { - var o, a, u, h, p; if (t.defaultPrevented || !this.quill.isEnabled()) return; t.preventDefault(); const e = this.quill.getSelection(!0); if (e == null) return; - const n = (o = t.clipboardData) == null ? void 0 : o.getData("text/html"); - let s = (a = t.clipboardData) == null ? void 0 : a.getData("text/plain"); + const n = t.clipboardData?.getData("text/html"); + let s = t.clipboardData?.getData("text/plain"); if (!n && !s) { - const v = (u = t.clipboardData) == null ? void 0 : u.getData("text/uri-list"); - v && (s = this.normalizeURIList(v)); + const o = t.clipboardData?.getData("text/uri-list"); + o && (s = this.normalizeURIList(o)); } - const i = Array.from(((h = t.clipboardData) == null ? void 0 : h.files) || []); + const i = Array.from(t.clipboardData?.files || []); if (!n && i.length > 0) { this.quill.uploader.upload(e, i); return; } if (n && i.length > 0) { - const v = new DOMParser().parseFromString(n, "text/html"); - if (v.body.childElementCount === 1 && ((p = v.body.firstElementChild) == null ? void 0 : p.tagName) === "IMG") { + const o = new DOMParser().parseFromString(n, "text/html"); + if (o.body.childElementCount === 1 && o.body.firstElementChild?.tagName === "IMG") { this.quill.uploader.upload(e, i); return; } @@ -5820,12 +5806,12 @@ class Co extends zt { text: n, html: s }, i); - wd.log("onPaste", o, { + nd.log("onPaste", o, { text: n, html: s }); - const a = new B().retain(t.index).delete(t.length).concat(o); - this.quill.updateContents(a, w.sources.USER), this.quill.setSelection(a.length() - t.length, w.sources.SILENT), this.quill.scrollSelectionIntoView(); + const a = new k().retain(t.index).delete(t.length).concat(o); + this.quill.updateContents(a, A.sources.USER), this.quill.setSelection(a.length() - t.length, A.sources.SILENT), this.quill.scrollSelectionIntoView(); } prepareMatching(t, e) { const n = [], s = []; @@ -5840,21 +5826,14 @@ class Co extends zt { break; default: Array.from(t.querySelectorAll(o)).forEach((u) => { - if (e.has(u)) { - const h = e.get(u); - h == null || h.push(a); - } else - e.set(u, [a]); + e.has(u) ? e.get(u)?.push(a) : e.set(u, [a]); }); break; } }), [n, s]; } } -O(Co, "DEFAULTS", { - matchers: [] -}); -function je(r, t, e, n) { +function ke(r, t, e, n) { return n.query(t) ? r.reduce((s, i) => { if (!i.insert) return s; if (i.attributes && i.attributes[t]) @@ -5866,9 +5845,9 @@ function je(r, t, e, n) { ...o, ...i.attributes }); - }, new B()) : r; + }, new k()) : r; } -function Gn(r, t) { +function Mn(r, t) { let e = ""; for (let n = r.ops.length - 1; n >= 0 && e.length < t.length; --n) { const s = r.ops[n]; @@ -5877,66 +5856,66 @@ function Gn(r, t) { } return e.slice(-1 * t.length) === t; } -function be(r, t) { +function he(r, t) { if (!(r instanceof Element)) return !1; const e = t.query(r); return e && e.prototype instanceof Lt ? !1 : ["address", "article", "blockquote", "canvas", "dd", "div", "dl", "dt", "fieldset", "figcaption", "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header", "iframe", "li", "main", "nav", "ol", "output", "p", "pre", "section", "table", "td", "tr", "ul", "video"].includes(r.tagName.toLowerCase()); } -function xd(r, t) { - return r.previousElementSibling && r.nextElementSibling && !be(r.previousElementSibling, t) && !be(r.nextElementSibling, t); +function ld(r, t) { + return r.previousElementSibling && r.nextElementSibling && !he(r.previousElementSibling, t) && !he(r.nextElementSibling, t); } -const Tr = /* @__PURE__ */ new WeakMap(); -function Io(r) { - return r == null ? !1 : (Tr.has(r) || (r.tagName === "PRE" ? Tr.set(r, !0) : Tr.set(r, Io(r.parentNode))), Tr.get(r)); +const gr = /* @__PURE__ */ new WeakMap(); +function bo(r) { + return r == null ? !1 : (gr.has(r) || (r.tagName === "PRE" ? gr.set(r, !0) : gr.set(r, bo(r.parentNode))), gr.get(r)); } -function Si(r, t, e, n, s) { - return t.nodeType === t.TEXT_NODE ? n.reduce((i, o) => o(t, i, r), new B()) : t.nodeType === t.ELEMENT_NODE ? Array.from(t.childNodes || []).reduce((i, o) => { - let a = Si(r, o, e, n, s); +function hi(r, t, e, n, s) { + return t.nodeType === t.TEXT_NODE ? n.reduce((i, o) => o(t, i, r), new k()) : t.nodeType === t.ELEMENT_NODE ? Array.from(t.childNodes || []).reduce((i, o) => { + let a = hi(r, o, e, n, s); return o.nodeType === t.ELEMENT_NODE && (a = e.reduce((u, h) => h(o, u, r), a), a = (s.get(o) || []).reduce((u, h) => h(o, u, r), a)), i.concat(a); - }, new B()) : new B(); + }, new k()) : new k(); } -function Fs(r) { - return (t, e, n) => je(e, r, !0, n); +function Is(r) { + return (t, e, n) => ke(e, r, !0, n); } -function Ld(r, t, e) { - const n = Jt.keys(r), s = Ft.keys(r), i = we.keys(r), o = {}; +function od(r, t, e) { + const n = Wt.keys(r), s = Ut.keys(r), i = me.keys(r), o = {}; return n.concat(s).concat(i).forEach((a) => { - let u = e.query(a, D.ATTRIBUTE); - u != null && (o[u.attrName] = u.value(r), o[u.attrName]) || (u = Td[a], u != null && (u.attrName === a || u.keyName === a) && (o[u.attrName] = u.value(r) || void 0), u = Ll[a], u != null && (u.attrName === a || u.keyName === a) && (u = Ll[a], o[u.attrName] = u.value(r) || void 0)); + let u = e.query(a, M.ATTRIBUTE); + u != null && (o[u.attrName] = u.value(r), o[u.attrName]) || (u = sd[a], u != null && (u.attrName === a || u.keyName === a) && (o[u.attrName] = u.value(r) || void 0), u = hl[a], u != null && (u.attrName === a || u.keyName === a) && (u = hl[a], o[u.attrName] = u.value(r) || void 0)); }), Object.entries(o).reduce((a, u) => { let [h, p] = u; - return je(a, h, p, e); + return ke(a, h, p, e); }, t); } -function Sd(r, t, e) { +function ad(r, t, e) { const n = e.query(r); if (n == null) return t; if (n.prototype instanceof Lt) { const s = {}, i = n.value(r); if (i != null) - return s[n.blotName] = i, new B().insert(s, n.formats(r, e)); - } else if (n.prototype instanceof Un && !Gn(t, ` + return s[n.blotName] = i, new k().insert(s, n.formats(r, e)); + } else if (n.prototype instanceof Rn && !Mn(t, ` `) && t.insert(` `), "blotName" in n && "formats" in n && typeof n.formats == "function") - return je(t, n.blotName, n.formats(r, e), e); + return ke(t, n.blotName, n.formats(r, e), e); return t; } -function _d(r, t) { - return Gn(t, ` +function cd(r, t) { + return Mn(t, ` `) || t.insert(` `), t; } -function qd(r, t, e) { +function ud(r, t, e) { const n = e.query("code-block"), s = n && "formats" in n && typeof n.formats == "function" ? n.formats(r, e) : !0; - return je(t, "code-block", s, e); + return ke(t, "code-block", s, e); } -function Od() { - return new B(); +function hd() { + return new k(); } -function Cd(r, t, e) { +function fd(r, t, e) { const n = e.query(r); if (n == null || // @ts-expect-error - n.blotName !== "list" || !Gn(t, ` + n.blotName !== "list" || !Mn(t, ` `)) return t; let s = -1, i = r.parentNode; @@ -5945,28 +5924,28 @@ function Cd(r, t, e) { return s <= 0 ? t : t.reduce((o, a) => a.insert ? a.attributes && typeof a.attributes.indent == "number" ? o.push(a) : o.insert(a.insert, { indent: s, ...a.attributes || {} - }) : o, new B()); + }) : o, new k()); } -function Id(r, t, e) { +function dd(r, t, e) { const n = r; let s = n.tagName === "OL" ? "ordered" : "bullet"; const i = n.getAttribute("data-checked"); - return i && (s = i === "true" ? "checked" : "unchecked"), je(t, "list", s, e); + return i && (s = i === "true" ? "checked" : "unchecked"), ke(t, "list", s, e); } -function Sl(r, t, e) { - if (!Gn(t, ` +function fl(r, t, e) { + if (!Mn(t, ` `)) { - if (be(r, e) && (r.childNodes.length > 0 || r instanceof HTMLParagraphElement)) + if (he(r, e) && (r.childNodes.length > 0 || r instanceof HTMLParagraphElement)) return t.insert(` `); if (t.length() > 0 && r.nextSibling) { let n = r.nextSibling; for (; n != null; ) { - if (be(n, e)) + if (he(n, e)) return t.insert(` `); const s = e.query(n); - if (s && s.prototype instanceof Ct) + if (s && s.prototype instanceof qt) return t.insert(` `); n = n.firstChild; @@ -5975,49 +5954,50 @@ function Sl(r, t, e) { } return t; } -function Rd(r, t, e) { - var i; +function gd(r, t, e) { const n = {}, s = r.style || {}; - return s.fontStyle === "italic" && (n.italic = !0), s.textDecoration === "underline" && (n.underline = !0), s.textDecoration === "line-through" && (n.strike = !0), ((i = s.fontWeight) != null && i.startsWith("bold") || // @ts-expect-error Fix me later - parseInt(s.fontWeight, 10) >= 700) && (n.bold = !0), t = Object.entries(n).reduce((o, a) => { - let [u, h] = a; - return je(o, u, h, e); - }, t), parseFloat(s.textIndent || 0) > 0 ? new B().insert(" ").concat(t) : t; -} -function kd(r, t, e) { - var s, i; - const n = ((s = r.parentElement) == null ? void 0 : s.tagName) === "TABLE" ? r.parentElement : (i = r.parentElement) == null ? void 0 : i.parentElement; + return s.fontStyle === "italic" && (n.italic = !0), s.textDecoration === "underline" && (n.underline = !0), s.textDecoration === "line-through" && (n.strike = !0), (s.fontWeight?.startsWith("bold") || // @ts-expect-error Fix me later + parseInt(s.fontWeight, 10) >= 700) && (n.bold = !0), t = Object.entries(n).reduce((i, o) => { + let [a, u] = o; + return ke(i, a, u, e); + }, t), parseFloat(s.textIndent || 0) > 0 ? new k().insert(" ").concat(t) : t; +} +function pd(r, t, e) { + const n = r.parentElement?.tagName === "TABLE" ? r.parentElement : r.parentElement?.parentElement; if (n != null) { - const a = Array.from(n.querySelectorAll("tr")).indexOf(r) + 1; - return je(t, "table", a, e); + const i = Array.from(n.querySelectorAll("tr")).indexOf(r) + 1; + return ke(t, "table", i, e); } return t; } -function Bd(r, t, e) { - var s; +function md(r, t, e) { let n = r.data; - if (((s = r.parentElement) == null ? void 0 : s.tagName) === "O:P") + if (r.parentElement?.tagName === "O:P") return t.insert(n.trim()); - if (!Io(r)) { + if (!bo(r)) { if (n.trim().length === 0 && n.includes(` -`) && !xd(r, e)) +`) && !ld(r, e)) return t; - n = n.replace(/[^\S\u00a0]/g, " "), n = n.replace(/ {2,}/g, " "), (r.previousSibling == null && r.parentElement != null && be(r.parentElement, e) || r.previousSibling instanceof Element && be(r.previousSibling, e)) && (n = n.replace(/^ /, "")), (r.nextSibling == null && r.parentElement != null && be(r.parentElement, e) || r.nextSibling instanceof Element && be(r.nextSibling, e)) && (n = n.replace(/ $/, "")), n = n.replaceAll(" ", " "); + n = n.replace(/[^\S\u00a0]/g, " "), n = n.replace(/ {2,}/g, " "), (r.previousSibling == null && r.parentElement != null && he(r.parentElement, e) || r.previousSibling instanceof Element && he(r.previousSibling, e)) && (n = n.replace(/^ /, "")), (r.nextSibling == null && r.parentElement != null && he(r.parentElement, e) || r.nextSibling instanceof Element && he(r.nextSibling, e)) && (n = n.replace(/ $/, "")), n = n.replaceAll(" ", " "); } return t.insert(n); } -class Ro extends zt { - constructor(e, n) { - super(e, n); - O(this, "lastRecorded", 0); - O(this, "ignoreChange", !1); - O(this, "stack", { - undo: [], - redo: [] - }); - O(this, "currentRange", null); - this.quill.on(w.events.EDITOR_CHANGE, (s, i, o, a) => { - s === w.events.SELECTION_CHANGE ? i && a !== w.sources.SILENT && (this.currentRange = i) : s === w.events.TEXT_CHANGE && (this.ignoreChange || (!this.options.userOnly || a === w.sources.USER ? this.record(i, o) : this.transform(i)), this.currentRange = si(this.currentRange, i)); +class bd extends Yt { + static DEFAULTS = { + delay: 1e3, + maxStack: 100, + userOnly: !1 + }; + lastRecorded = 0; + ignoreChange = !1; + stack = { + undo: [], + redo: [] + }; + currentRange = null; + constructor(t, e) { + super(t, e), this.quill.on(A.events.EDITOR_CHANGE, (n, s, i, o) => { + n === A.events.SELECTION_CHANGE ? s && o !== A.sources.SILENT && (this.currentRange = s) : n === A.events.TEXT_CHANGE && (this.ignoreChange || (!this.options.userOnly || o === A.sources.USER ? this.record(s, i) : this.transform(s)), this.currentRange = Zs(this.currentRange, s)); }), this.quill.keyboard.addBinding({ key: "z", shortKey: !0 @@ -6028,19 +6008,19 @@ class Ro extends zt { }, this.redo.bind(this)), /Win/i.test(navigator.platform) && this.quill.keyboard.addBinding({ key: "y", shortKey: !0 - }, this.redo.bind(this)), this.quill.root.addEventListener("beforeinput", (s) => { - s.inputType === "historyUndo" ? (this.undo(), s.preventDefault()) : s.inputType === "historyRedo" && (this.redo(), s.preventDefault()); + }, this.redo.bind(this)), this.quill.root.addEventListener("beforeinput", (n) => { + n.inputType === "historyUndo" ? (this.undo(), n.preventDefault()) : n.inputType === "historyRedo" && (this.redo(), n.preventDefault()); }); } - change(e, n) { - if (this.stack[e].length === 0) return; - const s = this.stack[e].pop(); - if (!s) return; - const i = this.quill.getContents(), o = s.delta.invert(i); - this.stack[n].push({ - delta: o, - range: si(s.range, o) - }), this.lastRecorded = 0, this.ignoreChange = !0, this.quill.updateContents(s.delta, w.sources.USER), this.ignoreChange = !1, this.restoreSelection(s); + change(t, e) { + if (this.stack[t].length === 0) return; + const n = this.stack[t].pop(); + if (!n) return; + const s = this.quill.getContents(), i = n.delta.invert(s); + this.stack[e].push({ + delta: i, + range: Zs(n.range, i) + }), this.lastRecorded = 0, this.ignoreChange = !0, this.quill.updateContents(n.delta, A.sources.USER), this.ignoreChange = !1, this.restoreSelection(n); } clear() { this.stack = { @@ -6051,68 +6031,63 @@ class Ro extends zt { cutoff() { this.lastRecorded = 0; } - record(e, n) { - if (e.ops.length === 0) return; + record(t, e) { + if (t.ops.length === 0) return; this.stack.redo = []; - let s = e.invert(n), i = this.currentRange; - const o = Date.now(); + let n = t.invert(e), s = this.currentRange; + const i = Date.now(); if ( // @ts-expect-error Fix me later - this.lastRecorded + this.options.delay > o && this.stack.undo.length > 0 + this.lastRecorded + this.options.delay > i && this.stack.undo.length > 0 ) { - const a = this.stack.undo.pop(); - a && (s = s.compose(a.delta), i = a.range); + const o = this.stack.undo.pop(); + o && (n = n.compose(o.delta), s = o.range); } else - this.lastRecorded = o; - s.length() !== 0 && (this.stack.undo.push({ - delta: s, - range: i + this.lastRecorded = i; + n.length() !== 0 && (this.stack.undo.push({ + delta: n, + range: s }), this.stack.undo.length > this.options.maxStack && this.stack.undo.shift()); } redo() { this.change("redo", "undo"); } - transform(e) { - _l(this.stack.undo, e), _l(this.stack.redo, e); + transform(t) { + dl(this.stack.undo, t), dl(this.stack.redo, t); } undo() { this.change("undo", "redo"); } - restoreSelection(e) { - if (e.range) - this.quill.setSelection(e.range, w.sources.USER); + restoreSelection(t) { + if (t.range) + this.quill.setSelection(t.range, A.sources.USER); else { - const n = Dd(this.quill.scroll, e.delta); - this.quill.setSelection(n, w.sources.USER); + const e = vd(this.quill.scroll, t.delta); + this.quill.setSelection(e, A.sources.USER); } } } -O(Ro, "DEFAULTS", { - delay: 1e3, - maxStack: 100, - userOnly: !1 -}); -function _l(r, t) { +function dl(r, t) { let e = t; for (let n = r.length - 1; n >= 0; n -= 1) { const s = r[n]; r[n] = { delta: e.transform(s.delta, !0), - range: s.range && si(s.range, e) + range: s.range && Zs(s.range, e) }, e = s.delta.transform(e), r[n].delta.length() === 0 && r.splice(n, 1); } } -function Md(r, t) { +function yd(r, t) { const e = t.ops[t.ops.length - 1]; return e == null ? !1 : e.insert != null ? typeof e.insert == "string" && e.insert.endsWith(` -`) : e.attributes != null ? Object.keys(e.attributes).some((n) => r.query(n, D.BLOCK) != null) : !1; +`) : e.attributes != null ? Object.keys(e.attributes).some((n) => r.query(n, M.BLOCK) != null) : !1; } -function Dd(r, t) { +function vd(r, t) { const e = t.reduce((s, i) => s + (i.delete || 0), 0); let n = t.length() - e; - return Md(r, t) && (n -= 1), n; + return yd(r, t) && (n -= 1), n; } -function si(r, t) { +function Zs(r, t) { if (!r) return r; const e = t.transformPosition(r.index), n = t.transformPosition(r.index + r.length); return { @@ -6120,34 +6095,32 @@ function si(r, t) { length: n - e }; } -class ko extends zt { +class yo extends Yt { constructor(t, e) { super(t, e), t.root.addEventListener("drop", (n) => { - var o; n.preventDefault(); let s = null; if (document.caretRangeFromPoint) s = document.caretRangeFromPoint(n.clientX, n.clientY); else if (document.caretPositionFromPoint) { - const a = document.caretPositionFromPoint(n.clientX, n.clientY); - s = document.createRange(), s.setStart(a.offsetNode, a.offset), s.setEnd(a.offsetNode, a.offset); + const o = document.caretPositionFromPoint(n.clientX, n.clientY); + s = document.createRange(), s.setStart(o.offsetNode, o.offset), s.setEnd(o.offsetNode, o.offset); } const i = s && t.selection.normalizeNative(s); if (i) { - const a = t.selection.normalizedToRange(i); - (o = n.dataTransfer) != null && o.files && this.upload(a, n.dataTransfer.files); + const o = t.selection.normalizedToRange(i); + n.dataTransfer?.files && this.upload(o, n.dataTransfer.files); } }); } upload(t, e) { const n = []; Array.from(e).forEach((s) => { - var i; - s && ((i = this.options.mimetypes) != null && i.includes(s.type)) && n.push(s); + s && this.options.mimetypes?.includes(s.type) && n.push(s); }), n.length > 0 && this.options.handler.call(this, t, n); } } -ko.DEFAULTS = { +yo.DEFAULTS = { mimetypes: ["image/png", "image/jpeg"], handler(r, t) { if (!this.quill.scroll.query("image")) @@ -6161,22 +6134,22 @@ ko.DEFAULTS = { Promise.all(e).then((n) => { const s = n.reduce((i, o) => i.insert({ image: o - }), new B().retain(r.index).delete(r.length)); + }), new k().retain(r.index).delete(r.length)); this.quill.updateContents(s, R.sources.USER), this.quill.setSelection(r.index + n.length, R.sources.SILENT); }); } }; -const jd = ["insertText", "insertReplacementText"]; -class $d extends zt { +const Ed = ["insertText", "insertReplacementText"]; +class Ad extends Yt { constructor(t, e) { super(t, e), t.root.addEventListener("beforeinput", (n) => { this.handleBeforeInput(n); - }), /Android/i.test(navigator.userAgent) || t.on(w.events.COMPOSITION_BEFORE_START, () => { + }), /Android/i.test(navigator.userAgent) || t.on(A.events.COMPOSITION_BEFORE_START, () => { this.handleCompositionStart(); }); } deleteRange(t) { - Li({ + ui({ range: t, quill: this.quill }); @@ -6186,18 +6159,18 @@ class $d extends zt { if (t.length === 0) return !1; if (e) { const n = this.quill.getFormat(t.index, 1); - this.deleteRange(t), this.quill.updateContents(new B().retain(t.index).insert(e, n), w.sources.USER); + this.deleteRange(t), this.quill.updateContents(new k().retain(t.index).insert(e, n), A.sources.USER); } else this.deleteRange(t); - return this.quill.setSelection(t.index + e.length, 0, w.sources.SILENT), !0; + return this.quill.setSelection(t.index + e.length, 0, A.sources.SILENT), !0; } handleBeforeInput(t) { - if (this.quill.composition.isComposing || t.defaultPrevented || !jd.includes(t.inputType)) + if (this.quill.composition.isComposing || t.defaultPrevented || !Ed.includes(t.inputType)) return; const e = t.getTargetRanges ? t.getTargetRanges()[0] : null; if (!e || e.collapsed === !0) return; - const n = Pd(t); + const n = wd(t); if (n == null) return; const s = this.quill.selection.normalizeNative(e), i = s ? this.quill.selection.normalizedToRange(s) : null; @@ -6208,39 +6181,37 @@ class $d extends zt { t && this.replaceText(t); } } -function Pd(r) { - var t; - return typeof r.data == "string" ? r.data : (t = r.dataTransfer) != null && t.types.includes("text/plain") ? r.dataTransfer.getData("text/plain") : null; +function wd(r) { + return typeof r.data == "string" ? r.data : r.dataTransfer?.types.includes("text/plain") ? r.dataTransfer.getData("text/plain") : null; } -const Ud = /Mac/i.test(navigator.platform), Fd = 100, Hd = (r) => !!(r.key === "ArrowLeft" || r.key === "ArrowRight" || // RTL scripts or moving from the end of the previous line -r.key === "ArrowUp" || r.key === "ArrowDown" || r.key === "Home" || Ud && r.key === "a" && r.ctrlKey === !0); -class zd extends zt { - constructor(e, n) { - super(e, n); - O(this, "isListening", !1); - O(this, "selectionChangeDeadline", 0); - this.handleArrowKeys(), this.handleNavigationShortcuts(); +const Nd = /Mac/i.test(navigator.platform), Td = 100, xd = (r) => !!(r.key === "ArrowLeft" || r.key === "ArrowRight" || // RTL scripts or moving from the end of the previous line +r.key === "ArrowUp" || r.key === "ArrowDown" || r.key === "Home" || Nd && r.key === "a" && r.ctrlKey === !0); +class Ld extends Yt { + isListening = !1; + selectionChangeDeadline = 0; + constructor(t, e) { + super(t, e), this.handleArrowKeys(), this.handleNavigationShortcuts(); } handleArrowKeys() { this.quill.keyboard.addBinding({ key: ["ArrowLeft", "ArrowRight"], offset: 0, shiftKey: null, - handler(e, n) { + handler(t, e) { let { - line: s, - event: i - } = n; - if (!(s instanceof Pt) || !s.uiNode) + line: n, + event: s + } = e; + if (!(n instanceof Pt) || !n.uiNode) return !0; - const o = getComputedStyle(s.domNode).direction === "rtl"; - return o && i.key !== "ArrowRight" || !o && i.key !== "ArrowLeft" ? !0 : (this.quill.setSelection(e.index - 1, e.length + (i.shiftKey ? 1 : 0), w.sources.USER), !1); + const i = getComputedStyle(n.domNode).direction === "rtl"; + return i && s.key !== "ArrowRight" || !i && s.key !== "ArrowLeft" ? !0 : (this.quill.setSelection(t.index - 1, t.length + (s.shiftKey ? 1 : 0), A.sources.USER), !1); } }); } handleNavigationShortcuts() { - this.quill.root.addEventListener("keydown", (e) => { - !e.defaultPrevented && Hd(e) && this.ensureListeningToSelectionChange(); + this.quill.root.addEventListener("keydown", (t) => { + !t.defaultPrevented && xd(t) && this.ensureListeningToSelectionChange(); }); } /** @@ -6250,44 +6221,44 @@ class zd extends zt { * the selection within the handler of a `selectionchange` event. */ ensureListeningToSelectionChange() { - if (this.selectionChangeDeadline = Date.now() + Fd, this.isListening) return; + if (this.selectionChangeDeadline = Date.now() + Td, this.isListening) return; this.isListening = !0; - const e = () => { + const t = () => { this.isListening = !1, Date.now() <= this.selectionChangeDeadline && this.handleSelectionChange(); }; - document.addEventListener("selectionchange", e, { + document.addEventListener("selectionchange", t, { once: !0 }); } handleSelectionChange() { - const e = document.getSelection(); - if (!e) return; - const n = e.getRangeAt(0); - if (n.collapsed !== !0 || n.startOffset !== 0) return; - const s = this.quill.scroll.find(n.startContainer); - if (!(s instanceof Pt) || !s.uiNode) return; - const i = document.createRange(); - i.setStartAfter(s.uiNode), i.setEndAfter(s.uiNode), e.removeAllRanges(), e.addRange(i); - } -} -w.register({ - "blots/block": pt, - "blots/block/embed": Ct, - "blots/break": Ht, - "blots/container": Me, - "blots/cursor": an, - "blots/embed": Ei, - "blots/inline": te, - "blots/scroll": en, - "blots/text": Ut, - "modules/clipboard": Co, - "modules/history": Ro, - "modules/keyboard": jr, - "modules/uploader": ko, - "modules/input": $d, - "modules/uiNode": zd + const t = document.getSelection(); + if (!t) return; + const e = t.getRangeAt(0); + if (e.collapsed !== !0 || e.startOffset !== 0) return; + const n = this.quill.scroll.find(e.startContainer); + if (!(n instanceof Pt) || !n.uiNode) return; + const s = document.createRange(); + s.setStartAfter(n.uiNode), s.setEndAfter(n.uiNode), t.removeAllRanges(), t.addRange(s); + } +} +A.register({ + "blots/block": gt, + "blots/block/embed": qt, + "blots/break": Ft, + "blots/container": Re, + "blots/cursor": Dt, + "blots/embed": si, + "blots/inline": At, + "blots/scroll": If, + "blots/text": $t, + "modules/clipboard": id, + "modules/history": bd, + "modules/keyboard": Cr, + "modules/uploader": yo, + "modules/input": Ad, + "modules/uiNode": Ld }); -class Kd extends Ft { +class Sd extends Ut { add(t, e) { let n = 0; if (e === "+1" || e === "-1") { @@ -6303,25 +6274,27 @@ class Kd extends Ft { return parseInt(super.value(t), 10) || void 0; } } -const Gd = new Kd("indent", "ql-indent", { - scope: D.BLOCK, +const _d = new Sd("indent", "ql-indent", { + scope: M.BLOCK, // @ts-expect-error whitelist: [1, 2, 3, 4, 5, 6, 7, 8] }); -class ii extends pt { +class Cd extends gt { + static blotName = "blockquote"; + static tagName = "blockquote"; } -O(ii, "blotName", "blockquote"), O(ii, "tagName", "blockquote"); -class li extends pt { +class qd extends gt { + static blotName = "header"; + static tagName = ["H1", "H2", "H3", "H4", "H5", "H6"]; static formats(t) { return this.tagName.indexOf(t.tagName) + 1; } } -O(li, "blotName", "header"), O(li, "tagName", ["H1", "H2", "H3", "H4", "H5", "H6"]); -class Vn extends Me { +class Dn extends Re { } -Vn.blotName = "list-container"; -Vn.tagName = "OL"; -class Wn extends pt { +Dn.blotName = "list-container"; +Dn.tagName = "OL"; +class jn extends gt { static create(t) { const e = super.create(); return e.setAttribute("data-list", t), e; @@ -6330,7 +6303,7 @@ class Wn extends pt { return t.getAttribute("data-list") || void 0; } static register() { - w.register(Vn); + A.register(Dn); } constructor(t, e) { super(t, e); @@ -6345,11 +6318,13 @@ class Wn extends pt { t === this.statics.blotName && e ? this.domNode.setAttribute("data-list", e) : super.format(t, e); } } -Wn.blotName = "list"; -Wn.tagName = "LI"; -Vn.allowedChildren = [Wn]; -Wn.requiredContainer = Vn; -class Hn extends te { +jn.blotName = "list"; +jn.tagName = "LI"; +Dn.allowedChildren = [jn]; +jn.requiredContainer = Dn; +class fi extends At { + static blotName = "bold"; + static tagName = ["STRONG", "B"]; static create() { return super.create(); } @@ -6360,11 +6335,15 @@ class Hn extends te { super.optimize(t), this.domNode.tagName !== this.statics.tagName[0] && this.replaceWith(this.statics.blotName); } } -O(Hn, "blotName", "bold"), O(Hn, "tagName", ["STRONG", "B"]); -class oi extends Hn { +class Od extends fi { + static blotName = "italic"; + static tagName = ["EM", "I"]; } -O(oi, "blotName", "italic"), O(oi, "tagName", ["EM", "I"]); -class ye extends te { +class Ar extends At { + static blotName = "link"; + static tagName = "A"; + static SANITIZED_URL = "about:blank"; + static PROTOCOL_WHITELIST = ["http", "https", "mailto", "tel", "sms"]; static create(t) { const e = super.create(t); return e.setAttribute("href", this.sanitize(t)), e.setAttribute("rel", "noopener noreferrer"), e.setAttribute("target", "_blank"), e; @@ -6373,20 +6352,21 @@ class ye extends te { return t.getAttribute("href"); } static sanitize(t) { - return Bo(t, this.PROTOCOL_WHITELIST) ? t : this.SANITIZED_URL; + return vo(t, this.PROTOCOL_WHITELIST) ? t : this.SANITIZED_URL; } format(t, e) { t !== this.statics.blotName || !e ? super.format(t, e) : this.domNode.setAttribute("href", this.constructor.sanitize(e)); } } -O(ye, "blotName", "link"), O(ye, "tagName", "A"), O(ye, "SANITIZED_URL", "about:blank"), O(ye, "PROTOCOL_WHITELIST", ["http", "https", "mailto", "tel", "sms"]); -function Bo(r, t) { +function vo(r, t) { const e = document.createElement("a"); e.href = r; const n = e.href.slice(0, e.href.indexOf(":")); return t.indexOf(n) > -1; } -class ai extends te { +class Rd extends At { + static blotName = "script"; + static tagName = ["SUB", "SUP"]; static create(t) { return t === "super" ? document.createElement("sup") : t === "sub" ? document.createElement("sub") : super.create(t); } @@ -6395,14 +6375,18 @@ class ai extends te { if (t.tagName === "SUP") return "super"; } } -O(ai, "blotName", "script"), O(ai, "tagName", ["SUB", "SUP"]); -class ci extends Hn { +class Id extends fi { + static blotName = "strike"; + static tagName = ["S", "STRIKE"]; } -O(ci, "blotName", "strike"), O(ci, "tagName", ["S", "STRIKE"]); -class ui extends te { +class kd extends At { + static blotName = "underline"; + static tagName = "U"; } -O(ui, "blotName", "underline"), O(ui, "tagName", "U"); -class Lr extends Ei { +class Bd extends si { + static blotName = "formula"; + static className = "ql-formula"; + static tagName = "SPAN"; static create(t) { if (window.katex == null) throw new Error("Formula module requires KaTeX."); @@ -6422,47 +6406,50 @@ class Lr extends Ei { return `${t}`; } } -O(Lr, "blotName", "formula"), O(Lr, "className", "ql-formula"), O(Lr, "tagName", "SPAN"); -const ql = ["alt", "height", "width"]; -class hi extends Lt { +const gl = ["alt", "height", "width"]; +class Md extends Lt { + static blotName = "image"; + static tagName = "IMG"; static create(t) { const e = super.create(t); return typeof t == "string" && e.setAttribute("src", this.sanitize(t)), e; } static formats(t) { - return ql.reduce((e, n) => (t.hasAttribute(n) && (e[n] = t.getAttribute(n)), e), {}); + return gl.reduce((e, n) => (t.hasAttribute(n) && (e[n] = t.getAttribute(n)), e), {}); } static match(t) { return /\.(jpe?g|gif|png)$/.test(t) || /^data:image\/.+;base64/.test(t); } static sanitize(t) { - return Bo(t, ["http", "https", "data"]) ? t : "//:0"; + return vo(t, ["http", "https", "data"]) ? t : "//:0"; } static value(t) { return t.getAttribute("src"); } format(t, e) { - ql.indexOf(t) > -1 ? e ? this.domNode.setAttribute(t, e) : this.domNode.removeAttribute(t) : super.format(t, e); + gl.indexOf(t) > -1 ? e ? this.domNode.setAttribute(t, e) : this.domNode.removeAttribute(t) : super.format(t, e); } } -O(hi, "blotName", "image"), O(hi, "tagName", "IMG"); -const Ol = ["height", "width"]; -class Sr extends Ct { +const pl = ["height", "width"]; +class Dd extends qt { + static blotName = "video"; + static className = "ql-video"; + static tagName = "IFRAME"; static create(t) { const e = super.create(t); return e.setAttribute("frameborder", "0"), e.setAttribute("allowfullscreen", "true"), e.setAttribute("src", this.sanitize(t)), e; } static formats(t) { - return Ol.reduce((e, n) => (t.hasAttribute(n) && (e[n] = t.getAttribute(n)), e), {}); + return pl.reduce((e, n) => (t.hasAttribute(n) && (e[n] = t.getAttribute(n)), e), {}); } static sanitize(t) { - return ye.sanitize(t); + return Ar.sanitize(t); } static value(t) { return t.getAttribute("src"); } format(t, e) { - Ol.indexOf(t) > -1 ? e ? this.domNode.setAttribute(t, e) : this.domNode.removeAttribute(t) : super.format(t, e); + pl.indexOf(t) > -1 ? e ? this.domNode.setAttribute(t, e) : this.domNode.removeAttribute(t) : super.format(t, e); } html() { const { @@ -6471,31 +6458,30 @@ class Sr extends Ct { return `
    ${t}`; } } -O(Sr, "blotName", "video"), O(Sr, "className", "ql-video"), O(Sr, "tagName", "IFRAME"); -const Rn = new Ft("code-token", "hljs", { - scope: D.INLINE +const Ln = new Ut("code-token", "hljs", { + scope: M.INLINE }); -class ae extends te { +class re extends At { static formats(t, e) { for (; t != null && t !== e.domNode; ) { - if (t.classList && t.classList.contains(At.className)) + if (t.classList && t.classList.contains(wt.className)) return super.formats(t, e); t = t.parentNode; } } constructor(t, e, n) { - super(t, e, n), Rn.add(this.domNode, n); + super(t, e, n), Ln.add(this.domNode, n); } format(t, e) { - t !== ae.blotName ? super.format(t, e) : e ? Rn.add(this.domNode, e) : (Rn.remove(this.domNode), this.domNode.classList.remove(this.statics.className)); + t !== re.blotName ? super.format(t, e) : e ? Ln.add(this.domNode, e) : (Ln.remove(this.domNode), this.domNode.classList.remove(this.statics.className)); } optimize() { - super.optimize(...arguments), Rn.value(this.domNode) || this.unwrap(); + super.optimize(...arguments), Ln.value(this.domNode) || this.unwrap(); } } -ae.blotName = "code-token"; -ae.className = "ql-token"; -class Ot extends At { +re.blotName = "code-token"; +re.className = "ql-token"; +class Ct extends wt { static create(t) { const e = super.create(t); return typeof t == "string" && e.setAttribute("data-language", t), e; @@ -6510,37 +6496,37 @@ class Ot extends At { t === this.statics.blotName && e ? this.domNode.setAttribute("data-language", e) : super.format(t, e); } replaceWith(t, e) { - return this.formatAt(0, this.length(), ae.blotName, !1), super.replaceWith(t, e); + return this.formatAt(0, this.length(), re.blotName, !1), super.replaceWith(t, e); } } -class Bn extends De { +class _n extends Ie { attach() { super.attach(), this.forceNext = !1, this.scroll.emitMount(this); } format(t, e) { - t === Ot.blotName && (this.forceNext = !0, this.children.forEach((n) => { + t === Ct.blotName && (this.forceNext = !0, this.children.forEach((n) => { n.format(t, e); })); } formatAt(t, e, n, s) { - n === Ot.blotName && (this.forceNext = !0), super.formatAt(t, e, n, s); + n === Ct.blotName && (this.forceNext = !0), super.formatAt(t, e, n, s); } highlight(t) { let e = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : !1; if (this.children.head == null) return; const s = `${Array.from(this.domNode.childNodes).filter((o) => o !== this.uiNode).map((o) => o.textContent).join(` `)} -`, i = Ot.formats(this.children.head.domNode); +`, i = Ct.formats(this.children.head.domNode); if (e || this.forceNext || this.cachedText !== s) { if (s.trim().length > 0 || this.cachedText == null) { - const o = this.children.reduce((u, h) => u.concat(yo(h, !1)), new B()), a = t(s, i); + const o = this.children.reduce((u, h) => u.concat(ro(h, !1)), new k()), a = t(s, i); o.diff(a).reduce((u, h) => { let { retain: p, attributes: v } = h; return p ? (v && Object.keys(v).forEach((f) => { - [Ot.blotName, ae.blotName].includes(f) && this.formatAt(u, p, f, v[f]); + [Ct.blotName, re.blotName].includes(f) && this.formatAt(u, p, f, v[f]); }), u + p) : u; }, 0); } @@ -6549,21 +6535,21 @@ class Bn extends De { } html(t, e) { const [n] = this.children.find(t); - return `
    -${Dr(this.code(t, e))}
    +    return `
    +${_r(this.code(t, e))}
     
    `; } optimize(t) { if (super.optimize(t), this.parent != null && this.children.head != null && this.uiNode != null) { - const e = Ot.formats(this.children.head.domNode); + const e = Ct.formats(this.children.head.domNode); e !== this.uiNode.value && (this.uiNode.value = e); } } } -Bn.allowedChildren = [Ot]; -Ot.requiredContainer = Bn; -Ot.allowedChildren = [ae, an, Ut, Ht]; -const Vd = (r, t, e) => { +_n.allowedChildren = [Ct]; +Ct.requiredContainer = _n; +Ct.allowedChildren = [re, Dt, $t, Ft]; +const jd = (r, t, e) => { if (typeof r.versionString == "string") { const n = r.versionString.split(".")[0]; if (parseInt(n, 10) >= 11) @@ -6573,9 +6559,9 @@ const Vd = (r, t, e) => { } return r.highlight(t, e).value; }; -class Mo extends zt { +class Eo extends Yt { static register() { - w.register(ae, !0), w.register(Ot, !0), w.register(Bn, !0); + A.register(re, !0), A.register(Ct, !0), A.register(_n, !0); } constructor(t, e) { if (super(t, e), this.options.hljs == null) @@ -6588,8 +6574,8 @@ class Mo extends zt { }, {}), this.highlightBlot = this.highlightBlot.bind(this), this.initListener(), this.initTimer(); } initListener() { - this.quill.on(w.events.SCROLL_BLOT_MOUNT, (t) => { - if (!(t instanceof Bn)) return; + this.quill.on(A.events.SCROLL_BLOT_MOUNT, (t) => { + if (!(t instanceof _n)) return; const e = this.quill.root.ownerDocument.createElement("select"); this.options.languages.forEach((n) => { let { @@ -6599,13 +6585,13 @@ class Mo extends zt { const o = e.ownerDocument.createElement("option"); o.textContent = i, o.setAttribute("value", s), e.appendChild(o); }), e.addEventListener("change", () => { - t.format(Ot.blotName, e.value), this.quill.root.focus(), this.highlight(t, !0); - }), t.uiNode == null && (t.attachUI(e), t.children.head && (e.value = Ot.formats(t.children.head.domNode))); + t.format(Ct.blotName, e.value), this.quill.root.focus(), this.highlight(t, !0); + }), t.uiNode == null && (t.attachUI(e), t.children.head && (e.value = Ct.formats(t.children.head.domNode))); }); } initTimer() { let t = null; - this.quill.on(w.events.SCROLL_OPTIMIZE, () => { + this.quill.on(A.events.SCROLL_OPTIMIZE, () => { t && clearTimeout(t), t = setTimeout(() => { this.highlight(), t = null; }, this.options.interval); @@ -6614,34 +6600,34 @@ class Mo extends zt { highlight() { let t = arguments.length > 0 && arguments[0] !== void 0 ? arguments[0] : null, e = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : !1; if (this.quill.selection.composing) return; - this.quill.update(w.sources.USER); + this.quill.update(A.sources.USER); const n = this.quill.getSelection(); - (t == null ? this.quill.scroll.descendants(Bn) : [t]).forEach((i) => { + (t == null ? this.quill.scroll.descendants(_n) : [t]).forEach((i) => { i.highlight(this.highlightBlot, e); - }), this.quill.update(w.sources.SILENT), n != null && this.quill.setSelection(n, w.sources.SILENT); + }), this.quill.update(A.sources.SILENT), n != null && this.quill.setSelection(n, A.sources.SILENT); } highlightBlot(t) { let e = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : "plain"; if (e = this.languages[e] ? e : "plain", e === "plain") - return Dr(t).split(` + return _r(t).split(` `).reduce((s, i, o) => (o !== 0 && s.insert(` `, { - [At.blotName]: e - }), s.insert(i)), new B()); + [wt.blotName]: e + }), s.insert(i)), new k()); const n = this.quill.root.ownerDocument.createElement("div"); - return n.classList.add(At.className), n.innerHTML = Vd(this.options.hljs, e, t), Si(this.quill.scroll, n, [(s, i) => { - const o = Rn.value(s); - return o ? i.compose(new B().retain(i.length(), { - [ae.blotName]: o + return n.classList.add(wt.className), n.innerHTML = jd(this.options.hljs, e, t), hi(this.quill.scroll, n, [(s, i) => { + const o = Ln.value(s); + return o ? i.compose(new k().retain(i.length(), { + [re.blotName]: o })) : i; }], [(s, i) => s.data.split(` `).reduce((o, a, u) => (u !== 0 && o.insert(` `, { - [At.blotName]: e + [wt.blotName]: e }), o.insert(a)), i)], /* @__PURE__ */ new WeakMap()); } } -Mo.DEFAULTS = { +Eo.DEFAULTS = { hljs: window.hljs, interval: 1e3, languages: [{ @@ -6688,10 +6674,12 @@ Mo.DEFAULTS = { label: "SQL" }] }; -const Dn = class Dn extends pt { +class jt extends gt { + static blotName = "table"; + static tagName = "TD"; static create(t) { const e = super.create(); - return t ? e.setAttribute("data-row", t) : e.setAttribute("data-row", _i()), e; + return t ? e.setAttribute("data-row", t) : e.setAttribute("data-row", di()), e; } static formats(t) { if (t.hasAttribute("data-row")) @@ -6701,7 +6689,7 @@ const Dn = class Dn extends pt { return this.parent ? this.parent.children.indexOf(this) : -1; } format(t, e) { - t === Dn.blotName && e ? this.domNode.setAttribute("data-row", e) : super.format(t, e); + t === jt.blotName && e ? this.domNode.setAttribute("data-row", e) : super.format(t, e); } row() { return this.parent; @@ -6712,10 +6700,10 @@ const Dn = class Dn extends pt { table() { return this.row() && this.row().table(); } -}; -O(Dn, "blotName", "table"), O(Dn, "tagName", "TD"); -let $t = Dn; -class ce extends Me { +} +class _e extends Re { + static blotName = "table-row"; + static tagName = "TR"; checkMerge() { if (super.checkMerge() && this.next.children.head != null) { const t = this.children.head.formats(), e = this.children.tail.formats(), n = this.next.children.head.formats(), s = this.next.children.tail.formats(); @@ -6740,18 +6728,20 @@ class ce extends Me { return this.parent && this.parent.parent; } } -O(ce, "blotName", "table-row"), O(ce, "tagName", "TR"); -class Qt extends Me { +class de extends Re { + static blotName = "table-body"; + static tagName = "TBODY"; } -O(Qt, "blotName", "table-body"), O(Qt, "tagName", "TBODY"); -class un extends Me { +class wr extends Re { + static blotName = "table-container"; + static tagName = "TABLE"; balanceCells() { - const t = this.descendants(ce), e = t.reduce((n, s) => Math.max(s.children.length, n), 0); + const t = this.descendants(_e), e = t.reduce((n, s) => Math.max(s.children.length, n), 0); t.forEach((n) => { new Array(e - n.children.length).fill(0).forEach(() => { let s; - n.children.head != null && (s = $t.formats(n.children.head.domNode)); - const i = this.scroll.create($t.blotName, s); + n.children.head != null && (s = jt.formats(n.children.head.domNode)); + const i = this.scroll.create(jt.blotName, s); n.appendChild(i), i.optimize(); }); }); @@ -6760,25 +6750,25 @@ class un extends Me { return this.rows().map((e) => e.children.at(t)); } deleteColumn(t) { - const [e] = this.descendant(Qt); + const [e] = this.descendant(de); e == null || e.children.head == null || e.children.forEach((n) => { const s = n.children.at(t); - s != null && s.remove(); + s?.remove(); }); } insertColumn(t) { - const [e] = this.descendant(Qt); + const [e] = this.descendant(de); e == null || e.children.head == null || e.children.forEach((n) => { - const s = n.children.at(t), i = $t.formats(n.children.head.domNode), o = this.scroll.create($t.blotName, i); + const s = n.children.at(t), i = jt.formats(n.children.head.domNode), o = this.scroll.create(jt.blotName, i); n.insertBefore(o, s); }); } insertRow(t) { - const [e] = this.descendant(Qt); + const [e] = this.descendant(de); if (e == null || e.children.head == null) return; - const n = _i(), s = this.scroll.create(ce.blotName); + const n = di(), s = this.scroll.create(_e.blotName); e.children.head.children.forEach(() => { - const o = this.scroll.create($t.blotName, n); + const o = this.scroll.create(jt.blotName, n); s.appendChild(o); }); const i = e.children.at(t); @@ -6789,47 +6779,46 @@ class un extends Me { return t == null ? [] : t.children.map((e) => e); } } -O(un, "blotName", "table-container"), O(un, "tagName", "TABLE"); -un.allowedChildren = [Qt]; -Qt.requiredContainer = un; -Qt.allowedChildren = [ce]; -ce.requiredContainer = Qt; -ce.allowedChildren = [$t]; -$t.requiredContainer = ce; -function _i() { +wr.allowedChildren = [de]; +de.requiredContainer = wr; +de.allowedChildren = [_e]; +_e.requiredContainer = de; +_e.allowedChildren = [jt]; +jt.requiredContainer = _e; +function di() { return `row-${Math.random().toString(36).slice(2, 6)}`; } -class Wd extends zt { +class Pd extends Yt { static register() { - w.register($t), w.register(ce), w.register(Qt), w.register(un); + A.register(jt), A.register(_e), A.register(de), A.register(wr); } constructor() { super(...arguments), this.listenBalanceCells(); } balanceTables() { - this.quill.scroll.descendants(un).forEach((t) => { + this.quill.scroll.descendants(wr).forEach((t) => { t.balanceCells(); }); } deleteColumn() { const [t, , e] = this.getTable(); - e != null && (t.deleteColumn(e.cellOffset()), this.quill.update(w.sources.USER)); + e != null && (t.deleteColumn(e.cellOffset()), this.quill.update(A.sources.USER)); } deleteRow() { const [, t] = this.getTable(); - t != null && (t.remove(), this.quill.update(w.sources.USER)); + t != null && (t.remove(), this.quill.update(A.sources.USER)); } deleteTable() { const [t] = this.getTable(); if (t == null) return; const e = t.offset(); - t.remove(), this.quill.update(w.sources.USER), this.quill.setSelection(e, w.sources.SILENT); + t.remove(), this.quill.update(A.sources.USER), this.quill.setSelection(e, A.sources.SILENT); } getTable() { let t = arguments.length > 0 && arguments[0] !== void 0 ? arguments[0] : this.quill.getSelection(); if (t == null) return [null, null, null, -1]; const [e, n] = this.quill.getLine(t.index); - if (e == null || e.statics.blotName !== $t.blotName) + if (e == null || e.statics.blotName !== jt.blotName) return [null, null, null, -1]; const s = e.parent; return [s.parent.parent, s, e, n]; @@ -6840,9 +6829,9 @@ class Wd extends zt { const [n, s, i] = this.getTable(e); if (i == null) return; const o = i.cellOffset(); - n.insertColumn(o + t), this.quill.update(w.sources.USER); + n.insertColumn(o + t), this.quill.update(A.sources.USER); let a = s.rowOffset(); - t === 0 && (a += 1), this.quill.setSelection(e.index + a, e.length, w.sources.SILENT); + t === 0 && (a += 1), this.quill.setSelection(e.index + a, e.length, A.sources.SILENT); } insertColumnLeft() { this.insertColumn(0); @@ -6856,7 +6845,7 @@ class Wd extends zt { const [n, s, i] = this.getTable(e); if (i == null) return; const o = s.rowOffset(); - n.insertRow(o + t), this.quill.update(w.sources.USER), t > 0 ? this.quill.setSelection(e, w.sources.SILENT) : this.quill.setSelection(e.index + s.children.length, e.length, w.sources.SILENT); + n.insertRow(o + t), this.quill.update(A.sources.USER), t > 0 ? this.quill.setSelection(e, A.sources.SILENT) : this.quill.setSelection(e.index + s.children.length, e.length, A.sources.SILENT); } insertRowAbove() { this.insertRow(0); @@ -6871,40 +6860,38 @@ class Wd extends zt { const o = new Array(e).fill(` `).join(""); return i.insert(o, { - table: _i() + table: di() }); - }, new B().retain(n.index)); - this.quill.updateContents(s, w.sources.USER), this.quill.setSelection(n.index, w.sources.SILENT), this.balanceTables(); + }, new k().retain(n.index)); + this.quill.updateContents(s, A.sources.USER), this.quill.setSelection(n.index, A.sources.SILENT), this.balanceTables(); } listenBalanceCells() { - this.quill.on(w.events.SCROLL_OPTIMIZE, (t) => { - t.some((e) => ["TD", "TR", "TBODY", "TABLE"].includes(e.target.tagName) ? (this.quill.once(w.events.TEXT_CHANGE, (n, s, i) => { - i === w.sources.USER && this.balanceTables(); + this.quill.on(A.events.SCROLL_OPTIMIZE, (t) => { + t.some((e) => ["TD", "TR", "TBODY", "TABLE"].includes(e.target.tagName) ? (this.quill.once(A.events.TEXT_CHANGE, (n, s, i) => { + i === A.sources.USER && this.balanceTables(); }), !0) : !1); }); } } -const Cl = he("quill:toolbar"); -class qi extends zt { +const ml = ie("quill:toolbar"); +class gi extends Yt { constructor(t, e) { - var n, s; if (super(t, e), Array.isArray(this.options.container)) { - const i = document.createElement("div"); - i.setAttribute("role", "toolbar"), Zd(i, this.options.container), (s = (n = t.container) == null ? void 0 : n.parentNode) == null || s.insertBefore(i, t.container), this.container = i; + const n = document.createElement("div"); + n.setAttribute("role", "toolbar"), $d(n, this.options.container), t.container?.parentNode?.insertBefore(n, t.container), this.container = n; } else typeof this.options.container == "string" ? this.container = document.querySelector(this.options.container) : this.container = this.options.container; if (!(this.container instanceof HTMLElement)) { - Cl.error("Container required for toolbar", this.options); + ml.error("Container required for toolbar", this.options); return; } - this.container.classList.add("ql-toolbar"), this.controls = [], this.handlers = {}, this.options.handlers && Object.keys(this.options.handlers).forEach((i) => { - var a; - const o = (a = this.options.handlers) == null ? void 0 : a[i]; - o && this.addHandler(i, o); - }), Array.from(this.container.querySelectorAll("button, select")).forEach((i) => { - this.attach(i); - }), this.quill.on(w.events.EDITOR_CHANGE, () => { - const [i] = this.quill.selection.getRange(); - this.update(i); + this.container.classList.add("ql-toolbar"), this.controls = [], this.handlers = {}, this.options.handlers && Object.keys(this.options.handlers).forEach((n) => { + const s = this.options.handlers?.[n]; + s && this.addHandler(n, s); + }), Array.from(this.container.querySelectorAll("button, select")).forEach((n) => { + this.attach(n); + }), this.quill.on(A.events.EDITOR_CHANGE, () => { + const [n] = this.quill.selection.getRange(); + this.update(n); }); } addHandler(t, e) { @@ -6914,7 +6901,7 @@ class qi extends zt { let e = Array.from(t.classList).find((s) => s.indexOf("ql-") === 0); if (!e) return; if (e = e.slice(3), t.tagName === "BUTTON" && t.setAttribute("type", "button"), this.handlers[e] == null && this.quill.scroll.query(e) == null) { - Cl.warn("ignoring attaching to nonexistent format", e, t); + ml.warn("ignoring attaching to nonexistent format", e, t); return; } const n = t.tagName === "SELECT" ? "change" : "click"; @@ -6935,11 +6922,11 @@ class qi extends zt { this.quill.scroll.query(e).prototype instanceof Lt ) { if (i = prompt(`Enter ${e}`), !i) return; - this.quill.updateContents(new B().retain(o.index).delete(o.length).insert({ + this.quill.updateContents(new k().retain(o.index).delete(o.length).insert({ [e]: i - }), w.sources.USER); + }), A.sources.USER); } else - this.quill.format(e, i, w.sources.USER); + this.quill.format(e, i, A.sources.USER); this.update(o); }), this.controls.push([e, t]); } @@ -6970,32 +6957,32 @@ class qi extends zt { }); } } -qi.DEFAULTS = {}; -function Il(r, t, e) { +gi.DEFAULTS = {}; +function bl(r, t, e) { const n = document.createElement("button"); n.setAttribute("type", "button"), n.classList.add(`ql-${t}`), n.setAttribute("aria-pressed", "false"), e != null ? (n.value = e, n.setAttribute("aria-label", `${t}: ${e}`)) : n.setAttribute("aria-label", t), r.appendChild(n); } -function Zd(r, t) { +function $d(r, t) { Array.isArray(t[0]) || (t = [t]), t.forEach((e) => { const n = document.createElement("span"); n.classList.add("ql-formats"), e.forEach((s) => { if (typeof s == "string") - Il(n, s); + bl(n, s); else { const i = Object.keys(s)[0], o = s[i]; - Array.isArray(o) ? Yd(n, i, o) : Il(n, i, o); + Array.isArray(o) ? Ud(n, i, o) : bl(n, i, o); } }), r.appendChild(n); }); } -function Yd(r, t, e) { +function Ud(r, t, e) { const n = document.createElement("select"); n.classList.add(`ql-${t}`), e.forEach((s) => { const i = document.createElement("option"); s !== !1 ? i.setAttribute("value", String(s)) : i.setAttribute("selected", "selected"), n.appendChild(i); }), r.appendChild(n); } -qi.DEFAULTS = { +gi.DEFAULTS = { container: null, handlers: { clean() { @@ -7004,86 +6991,86 @@ qi.DEFAULTS = { if (r.length === 0) { const t = this.quill.getFormat(); Object.keys(t).forEach((e) => { - this.quill.scroll.query(e, D.INLINE) != null && this.quill.format(e, !1, w.sources.USER); + this.quill.scroll.query(e, M.INLINE) != null && this.quill.format(e, !1, A.sources.USER); }); } else - this.quill.removeFormat(r.index, r.length, w.sources.USER); + this.quill.removeFormat(r.index, r.length, A.sources.USER); }, direction(r) { const { align: t } = this.quill.getFormat(); - r === "rtl" && t == null ? this.quill.format("align", "right", w.sources.USER) : !r && t === "right" && this.quill.format("align", !1, w.sources.USER), this.quill.format("direction", r, w.sources.USER); + r === "rtl" && t == null ? this.quill.format("align", "right", A.sources.USER) : !r && t === "right" && this.quill.format("align", !1, A.sources.USER), this.quill.format("direction", r, A.sources.USER); }, indent(r) { const t = this.quill.getSelection(), e = this.quill.getFormat(t), n = parseInt(e.indent || 0, 10); if (r === "+1" || r === "-1") { let s = r === "+1" ? 1 : -1; - e.direction === "rtl" && (s *= -1), this.quill.format("indent", n + s, w.sources.USER); + e.direction === "rtl" && (s *= -1), this.quill.format("indent", n + s, A.sources.USER); } }, link(r) { - r === !0 && (r = prompt("Enter link URL:")), this.quill.format("link", r, w.sources.USER); + r === !0 && (r = prompt("Enter link URL:")), this.quill.format("link", r, A.sources.USER); }, list(r) { const t = this.quill.getSelection(), e = this.quill.getFormat(t); - r === "check" ? e.list === "checked" || e.list === "unchecked" ? this.quill.format("list", !1, w.sources.USER) : this.quill.format("list", "unchecked", w.sources.USER) : this.quill.format("list", r, w.sources.USER); + r === "check" ? e.list === "checked" || e.list === "unchecked" ? this.quill.format("list", !1, A.sources.USER) : this.quill.format("list", "unchecked", A.sources.USER) : this.quill.format("list", r, A.sources.USER); } } }; -const Xd = '', Qd = '', Jd = '', tg = '', eg = '', ng = '', rg = '', sg = '', Rl = '', ig = '', lg = '', og = '', ag = '', cg = '', ug = '', hg = '', fg = '', dg = '', gg = '', pg = '', mg = '', bg = '', yg = '', vg = '', Eg = '', Ag = '', wg = '', Ng = '', Tg = '', xg = '', Lg = '', Sg = '', _g = '', zn = { +const Fd = '', Hd = '', zd = '', Kd = '', Gd = '', Vd = '', Wd = '', Zd = '', yl = '', Yd = '', Xd = '', Qd = '', Jd = '', tg = '', eg = '', ng = '', rg = '', sg = '', ig = '', lg = '', og = '', ag = '', cg = '', ug = '', hg = '', fg = '', dg = '', gg = '', pg = '', mg = '', bg = '', yg = '', vg = '', kn = { align: { - "": Xd, - center: Qd, - right: Jd, - justify: tg + "": Fd, + center: Hd, + right: zd, + justify: Kd }, - background: eg, - blockquote: ng, - bold: rg, - clean: sg, - code: Rl, - "code-block": Rl, - color: ig, + background: Gd, + blockquote: Vd, + bold: Wd, + clean: Zd, + code: yl, + "code-block": yl, + color: Yd, direction: { - "": lg, - rtl: og + "": Xd, + rtl: Qd }, - formula: ag, + formula: Jd, header: { - 1: cg, - 2: ug, - 3: hg, - 4: fg, - 5: dg, - 6: gg + 1: tg, + 2: eg, + 3: ng, + 4: rg, + 5: sg, + 6: ig }, - italic: pg, - image: mg, + italic: lg, + image: og, indent: { - "+1": bg, - "-1": yg + "+1": ag, + "-1": cg }, - link: vg, + link: ug, list: { - bullet: Eg, - check: Ag, - ordered: wg + bullet: hg, + check: fg, + ordered: dg }, script: { - sub: Ng, - super: Tg + sub: gg, + super: pg }, - strike: xg, - table: Lg, - underline: Sg, - video: _g -}, qg = ''; -let kl = 0; -function Bl(r, t) { + strike: mg, + table: bg, + underline: yg, + video: vg +}, Eg = ''; +let vl = 0; +function El(r, t) { r.setAttribute(t, `${r.getAttribute(t) !== "true"}`); } -class $r { +class qr { constructor(t) { this.select = t, this.container = document.createElement("span"), this.buildPicker(), this.select.style.display = "none", this.select.parentNode.insertBefore(this.container, this.select), this.label.addEventListener("mousedown", () => { this.togglePicker(); @@ -7099,7 +7086,7 @@ class $r { }), this.select.addEventListener("change", this.update.bind(this)); } togglePicker() { - this.container.classList.toggle("ql-expanded"), Bl(this.label, "aria-expanded"), Bl(this.options, "aria-hidden"); + this.container.classList.toggle("ql-expanded"), El(this.label, "aria-expanded"), El(this.options, "aria-hidden"); } buildItem(t) { const e = document.createElement("span"); @@ -7120,11 +7107,11 @@ class $r { } buildLabel() { const t = document.createElement("span"); - return t.classList.add("ql-picker-label"), t.innerHTML = qg, t.tabIndex = "0", t.setAttribute("role", "button"), t.setAttribute("aria-expanded", "false"), this.container.appendChild(t), t; + return t.classList.add("ql-picker-label"), t.innerHTML = Eg, t.tabIndex = "0", t.setAttribute("role", "button"), t.setAttribute("aria-expanded", "false"), this.container.appendChild(t), t; } buildOptions() { const t = document.createElement("span"); - t.classList.add("ql-picker-options"), t.setAttribute("aria-hidden", "true"), t.tabIndex = "-1", t.id = `ql-picker-options-${kl}`, kl += 1, this.label.setAttribute("aria-controls", t.id), this.options = t, Array.from(this.select.options).forEach((e) => { + t.classList.add("ql-picker-options"), t.setAttribute("aria-hidden", "true"), t.tabIndex = "-1", t.id = `ql-picker-options-${vl}`, vl += 1, this.label.setAttribute("aria-controls", t.id), this.options = t, Array.from(this.select.options).forEach((e) => { const n = this.buildItem(e); t.appendChild(n), e.selected === !0 && this.selectItem(n); }), this.container.appendChild(t); @@ -7143,7 +7130,7 @@ class $r { selectItem(t) { let e = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : !1; const n = this.container.querySelector(".ql-selected"); - t !== n && (n != null && n.classList.remove("ql-selected"), t != null && (t.classList.add("ql-selected"), this.select.selectedIndex = Array.from(t.parentNode.children).indexOf(t), t.hasAttribute("data-value") ? this.label.setAttribute("data-value", t.getAttribute("data-value")) : this.label.removeAttribute("data-value"), t.hasAttribute("data-label") ? this.label.setAttribute("data-label", t.getAttribute("data-label")) : this.label.removeAttribute("data-label"), e && (this.select.dispatchEvent(new Event("change")), this.close()))); + t !== n && (n?.classList.remove("ql-selected"), t != null && (t.classList.add("ql-selected"), this.select.selectedIndex = Array.from(t.parentNode.children).indexOf(t), t.hasAttribute("data-value") ? this.label.setAttribute("data-value", t.getAttribute("data-value")) : this.label.removeAttribute("data-value"), t.hasAttribute("data-label") ? this.label.setAttribute("data-label", t.getAttribute("data-label")) : this.label.removeAttribute("data-label"), e && (this.select.dispatchEvent(new Event("change")), this.close()))); } update() { let t; @@ -7159,7 +7146,7 @@ class $r { this.label.classList.toggle("ql-active", e); } } -class Do extends $r { +class Ao extends qr { constructor(t, e) { super(t), this.label.innerHTML = e, this.container.classList.add("ql-color-picker"), Array.from(this.container.querySelectorAll(".ql-picker-item")).slice(0, 7).forEach((n) => { n.classList.add("ql-primary"); @@ -7175,7 +7162,7 @@ class Do extends $r { n && (n.tagName === "line" ? n.style.stroke = s : n.style.fill = s); } } -class jo extends $r { +class wo extends qr { constructor(t, e) { super(t), this.container.classList.add("ql-icon-picker"), Array.from(this.container.querySelectorAll(".ql-picker-item")).forEach((n) => { n.innerHTML = e[n.getAttribute("data-value") || ""]; @@ -7190,15 +7177,15 @@ class jo extends $r { } } } -const Og = (r) => { +const Ag = (r) => { const { overflowY: t } = getComputedStyle(r, null); return t !== "visible" && t !== "clip"; }; -class $o { +class No { constructor(t, e) { - this.quill = t, this.boundsContainer = e || document.body, this.root = t.addContainer("ql-tooltip"), this.root.innerHTML = this.constructor.TEMPLATE, Og(this.quill.root) && this.quill.root.addEventListener("scroll", () => { + this.quill = t, this.boundsContainer = e || document.body, this.root = t.addContainer("ql-tooltip"), this.root.innerHTML = this.constructor.TEMPLATE, Ag(this.quill.root) && this.quill.root.addEventListener("scroll", () => { this.root.style.marginTop = `${-1 * this.quill.root.scrollTop}px`; }), this.hide(); } @@ -7220,8 +7207,8 @@ class $o { this.root.classList.remove("ql-editing"), this.root.classList.remove("ql-hidden"); } } -const Cg = [!1, "center", "right", "justify"], Ig = ["#000000", "#e60000", "#ff9900", "#ffff00", "#008a00", "#0066cc", "#9933ff", "#ffffff", "#facccc", "#ffebcc", "#ffffcc", "#cce8cc", "#cce0f5", "#ebd6ff", "#bbbbbb", "#f06666", "#ffc266", "#ffff66", "#66b966", "#66a3e0", "#c285ff", "#888888", "#a10000", "#b26b00", "#b2b200", "#006100", "#0047b2", "#6b24b2", "#444444", "#5c0000", "#663d00", "#666600", "#003700", "#002966", "#3d1466"], Rg = [!1, "serif", "monospace"], kg = ["1", "2", "3", !1], Bg = ["small", !1, "large", "huge"]; -class Zn extends cn { +const wg = [!1, "center", "right", "justify"], Ng = ["#000000", "#e60000", "#ff9900", "#ffff00", "#008a00", "#0066cc", "#9933ff", "#ffffff", "#facccc", "#ffebcc", "#ffffcc", "#cce8cc", "#cce0f5", "#ebd6ff", "#bbbbbb", "#f06666", "#ffc266", "#ffff66", "#66b966", "#66a3e0", "#c285ff", "#888888", "#a10000", "#b26b00", "#b2b200", "#006100", "#0047b2", "#6b24b2", "#444444", "#5c0000", "#663d00", "#666600", "#003700", "#002966", "#3d1466"], Tg = [!1, "serif", "monospace"], xg = ["1", "2", "3", !1], Lg = ["small", !1, "large", "huge"]; +class Pn extends rn { constructor(t, e) { super(t, e); const n = (s) => { @@ -7258,13 +7245,13 @@ class Zn extends cn { } buildPickers(t, e) { this.pickers = Array.from(t).map((s) => { - if (s.classList.contains("ql-align") && (s.querySelector("option") == null && On(s, Cg), typeof e.align == "object")) - return new jo(s, e.align); + if (s.classList.contains("ql-align") && (s.querySelector("option") == null && Nn(s, wg), typeof e.align == "object")) + return new wo(s, e.align); if (s.classList.contains("ql-background") || s.classList.contains("ql-color")) { const i = s.classList.contains("ql-background") ? "background" : "color"; - return s.querySelector("option") == null && On(s, Ig, i === "background" ? "#ffffff" : "#000000"), new Do(s, e[i]); + return s.querySelector("option") == null && Nn(s, Ng, i === "background" ? "#ffffff" : "#000000"), new Ao(s, e[i]); } - return s.querySelector("option") == null && (s.classList.contains("ql-font") ? On(s, Rg) : s.classList.contains("ql-header") ? On(s, kg) : s.classList.contains("ql-size") && On(s, Bg)), new $r(s); + return s.querySelector("option") == null && (s.classList.contains("ql-font") ? Nn(s, Tg) : s.classList.contains("ql-header") ? Nn(s, xg) : s.classList.contains("ql-size") && Nn(s, Lg)), new qr(s); }); const n = () => { this.pickers.forEach((s) => { @@ -7274,7 +7261,7 @@ class Zn extends cn { this.quill.on(R.events.EDITOR_CHANGE, n); } } -Zn.DEFAULTS = ve({}, cn.DEFAULTS, { +Pn.DEFAULTS = fe({}, rn.DEFAULTS, { modules: { toolbar: { handlers: { @@ -7295,7 +7282,7 @@ Zn.DEFAULTS = ve({}, cn.DEFAULTS, { } } }); -class Po extends $o { +class To extends No { constructor(t, e) { super(t, e), this.textbox = this.root.querySelector('input[type="text"]'), this.listen(); } @@ -7332,7 +7319,7 @@ class Po extends $o { break; } case "video": - t = Mg(t); + t = Sg(t); // eslint-disable-next-line no-fallthrough case "formula": { if (!t) break; @@ -7353,23 +7340,24 @@ class Po extends $o { this.textbox.value = "", this.hide(); } } -function Mg(r) { +function Sg(r) { let t = r.match(/^(?:(https?):\/\/)?(?:(?:www|m)\.)?youtube\.com\/watch.*v=([a-zA-Z0-9_-]+)/) || r.match(/^(?:(https?):\/\/)?(?:(?:www|m)\.)?youtu\.be\/([a-zA-Z0-9_-]+)/); return t ? `${t[1] || "https"}://www.youtube.com/embed/${t[2]}?showinfo=0` : (t = r.match(/^(?:(https?):\/\/)?(?:www\.)?vimeo\.com\/(\d+)/)) ? `${t[1] || "https"}://player.vimeo.com/video/${t[2]}/` : r; } -function On(r, t) { +function Nn(r, t) { let e = arguments.length > 2 && arguments[2] !== void 0 ? arguments[2] : !1; t.forEach((n) => { const s = document.createElement("option"); n === e ? s.setAttribute("selected", "selected") : s.setAttribute("value", String(n)), r.appendChild(s); }); } -const Dg = [["bold", "italic", "link"], [{ +const _g = [["bold", "italic", "link"], [{ header: 1 }, { header: 2 }, "blockquote"]]; -class Uo extends Po { +class Cg extends To { + static TEMPLATE = ['', '
    ', '', '', "
    "].join(""); constructor(t, e) { super(t, e), this.quill.on(R.events.EDITOR_CHANGE, (n, s, i, o) => { if (n === R.events.SELECTION_CHANGE) @@ -7380,7 +7368,7 @@ class Uo extends Po { const u = this.quill.getBounds(s); u != null && this.position(u); } else { - const u = a[a.length - 1], h = this.quill.getIndex(u), p = Math.min(u.length() - 1, s.index + s.length - h), v = this.quill.getBounds(new Ie(h, p)); + const u = a[a.length - 1], h = this.quill.getIndex(u), p = Math.min(u.length() - 1, s.index + s.length - h), v = this.quill.getBounds(new Se(h, p)); v != null && this.position(v); } } else document.activeElement !== this.textbox && this.quill.hasFocus() && this.hide(); @@ -7408,57 +7396,54 @@ class Uo extends Po { return n.style.marginLeft = "", e !== 0 && (n.style.marginLeft = `${-1 * e - n.offsetWidth / 2}px`), e; } } -O(Uo, "TEMPLATE", ['', '
    ', '', '', "
    "].join("")); -class Fo extends Zn { +class xo extends Pn { constructor(t, e) { - e.modules.toolbar != null && e.modules.toolbar.container == null && (e.modules.toolbar.container = Dg), super(t, e), this.quill.container.classList.add("ql-bubble"); + e.modules.toolbar != null && e.modules.toolbar.container == null && (e.modules.toolbar.container = _g), super(t, e), this.quill.container.classList.add("ql-bubble"); } extendToolbar(t) { - this.tooltip = new Uo(this.quill, this.options.bounds), t.container != null && (this.tooltip.root.appendChild(t.container), this.buildButtons(t.container.querySelectorAll("button"), zn), this.buildPickers(t.container.querySelectorAll("select"), zn)); + this.tooltip = new Cg(this.quill, this.options.bounds), t.container != null && (this.tooltip.root.appendChild(t.container), this.buildButtons(t.container.querySelectorAll("button"), kn), this.buildPickers(t.container.querySelectorAll("select"), kn)); } } -Fo.DEFAULTS = ve({}, Zn.DEFAULTS, { +xo.DEFAULTS = fe({}, Pn.DEFAULTS, { modules: { toolbar: { handlers: { link(r) { - r ? this.quill.theme.tooltip.edit() : this.quill.format("link", !1, w.sources.USER); + r ? this.quill.theme.tooltip.edit() : this.quill.format("link", !1, A.sources.USER); } } } } }); -const jg = [[{ +const qg = [[{ header: ["1", "2", "3", !1] }], ["bold", "italic", "underline", "link"], [{ list: "ordered" }, { list: "bullet" }], ["clean"]]; -class Ho extends Po { - constructor() { - super(...arguments); - O(this, "preview", this.root.querySelector("a.ql-preview")); - } +class Og extends To { + static TEMPLATE = ['', '', '', ''].join(""); + preview = this.root.querySelector("a.ql-preview"); listen() { - super.listen(), this.root.querySelector("a.ql-action").addEventListener("click", (e) => { - this.root.classList.contains("ql-editing") ? this.save() : this.edit("link", this.preview.textContent), e.preventDefault(); - }), this.root.querySelector("a.ql-remove").addEventListener("click", (e) => { + super.listen(), this.root.querySelector("a.ql-action").addEventListener("click", (t) => { + this.root.classList.contains("ql-editing") ? this.save() : this.edit("link", this.preview.textContent), t.preventDefault(); + }), this.root.querySelector("a.ql-remove").addEventListener("click", (t) => { if (this.linkRange != null) { - const n = this.linkRange; - this.restoreFocus(), this.quill.formatText(n, "link", !1, R.sources.USER), delete this.linkRange; + const e = this.linkRange; + this.restoreFocus(), this.quill.formatText(e, "link", !1, R.sources.USER), delete this.linkRange; } - e.preventDefault(), this.hide(); - }), this.quill.on(R.events.SELECTION_CHANGE, (e, n, s) => { - if (e != null) { - if (e.length === 0 && s === R.sources.USER) { - const [i, o] = this.quill.scroll.descendant(ye, e.index); - if (i != null) { - this.linkRange = new Ie(e.index - o, i.length()); - const a = ye.formats(i.domNode); - this.preview.textContent = a, this.preview.setAttribute("href", a), this.show(); - const u = this.quill.getBounds(this.linkRange); - u != null && this.position(u); + t.preventDefault(), this.hide(); + }), this.quill.on(R.events.SELECTION_CHANGE, (t, e, n) => { + if (t != null) { + if (t.length === 0 && n === R.sources.USER) { + const [s, i] = this.quill.scroll.descendant(Ar, t.index); + if (s != null) { + this.linkRange = new Se(t.index - i, s.length()); + const o = Ar.formats(s.domNode); + this.preview.textContent = o, this.preview.setAttribute("href", o), this.show(); + const a = this.quill.getBounds(this.linkRange); + a != null && this.position(a); return; } } else @@ -7471,13 +7456,12 @@ class Ho extends Po { super.show(), this.root.removeAttribute("data-mode"); } } -O(Ho, "TEMPLATE", ['', '', '', ''].join("")); -class zo extends Zn { +class Lo extends Pn { constructor(t, e) { - e.modules.toolbar != null && e.modules.toolbar.container == null && (e.modules.toolbar.container = jg), super(t, e), this.quill.container.classList.add("ql-snow"); + e.modules.toolbar != null && e.modules.toolbar.container == null && (e.modules.toolbar.container = qg), super(t, e), this.quill.container.classList.add("ql-snow"); } extendToolbar(t) { - t.container != null && (t.container.classList.add("ql-snow"), this.buildButtons(t.container.querySelectorAll("button"), zn), this.buildPickers(t.container.querySelectorAll("select"), zn), this.tooltip = new Ho(this.quill, this.options.bounds), t.container.querySelector(".ql-link") && this.quill.keyboard.addBinding({ + t.container != null && (t.container.classList.add("ql-snow"), this.buildButtons(t.container.querySelectorAll("button"), kn), this.buildPickers(t.container.querySelectorAll("select"), kn), this.tooltip = new Og(this.quill, this.options.bounds), t.container.querySelector(".ql-link") && this.quill.keyboard.addBinding({ key: "k", shortKey: !0 }, (e, n) => { @@ -7485,7 +7469,7 @@ class zo extends Zn { })); } } -zo.DEFAULTS = ve({}, Zn.DEFAULTS, { +Lo.DEFAULTS = fe({}, Pn.DEFAULTS, { modules: { toolbar: { handlers: { @@ -7500,70 +7484,64 @@ zo.DEFAULTS = ve({}, Zn.DEFAULTS, { } = this.quill.theme; n.edit("link", e); } else - this.quill.format("link", !1, w.sources.USER); + this.quill.format("link", !1, A.sources.USER); } } } } }); -w.register({ - "attributors/attribute/direction": No, - "attributors/class/align": Eo, - "attributors/class/background": nd, - "attributors/class/color": ed, - "attributors/class/direction": To, - "attributors/class/font": So, - "attributors/class/size": qo, - "attributors/style/align": Ao, - "attributors/style/background": Ni, - "attributors/style/color": wi, - "attributors/style/direction": xo, - "attributors/style/font": _o, - "attributors/style/size": Oo +A.register({ + "attributors/attribute/direction": ao, + "attributors/class/align": io, + "attributors/class/background": Mf, + "attributors/class/color": Bf, + "attributors/class/direction": co, + "attributors/class/font": fo, + "attributors/class/size": po, + "attributors/style/align": lo, + "attributors/style/background": oi, + "attributors/style/color": li, + "attributors/style/direction": uo, + "attributors/style/font": go, + "attributors/style/size": mo }, !0); -w.register({ - "formats/align": Eo, - "formats/direction": To, - "formats/indent": Gd, - "formats/background": Ni, - "formats/color": wi, - "formats/font": So, - "formats/size": qo, - "formats/blockquote": ii, - "formats/code-block": At, - "formats/header": li, - "formats/list": Wn, - "formats/bold": Hn, - "formats/code": Ti, - "formats/italic": oi, - "formats/link": ye, - "formats/script": ai, - "formats/strike": ci, - "formats/underline": ui, - "formats/formula": Lr, - "formats/image": hi, - "formats/video": Sr, - "modules/syntax": Mo, - "modules/table": Wd, - "modules/toolbar": qi, - "themes/bubble": Fo, - "themes/snow": zo, - "ui/icons": zn, - "ui/picker": $r, - "ui/icon-picker": jo, - "ui/color-picker": Do, - "ui/tooltip": $o +A.register({ + "formats/align": io, + "formats/direction": co, + "formats/indent": _d, + "formats/background": oi, + "formats/color": li, + "formats/font": fo, + "formats/size": po, + "formats/blockquote": Cd, + "formats/code-block": wt, + "formats/header": qd, + "formats/list": jn, + "formats/bold": fi, + "formats/code": ai, + "formats/italic": Od, + "formats/link": Ar, + "formats/script": Rd, + "formats/strike": Id, + "formats/underline": kd, + "formats/formula": Bd, + "formats/image": Md, + "formats/video": Dd, + "modules/syntax": Eo, + "modules/table": Pd, + "modules/toolbar": gi, + "themes/bubble": xo, + "themes/snow": Lo, + "ui/icons": kn, + "ui/picker": qr, + "ui/icon-picker": wo, + "ui/color-picker": Ao, + "ui/tooltip": No }, !0); -const Zt = window.React, $g = window.React.createRef; -class Pr extends Zt.Component { - constructor(t) { - super(t), this.editingAreaRef = $g(), this.dirtyProps = [ - "modules", - "formats", - "bounds", - "theme", - "children" - ], this.cleanProps = [ +const Te = window.React, Al = window.React.createRef; +var Or = class extends Te.Component { + constructor(r) { + super(r), this.editingAreaRef = Al(), this.containerRef = Al(), this.dirtyProps = ["modules", "formats", "bounds", "theme", "children"], this.cleanProps = [ "id", "className", "style", @@ -7575,38 +7553,42 @@ class Pr extends Zt.Component { "onBlur", "onKeyPress", "onKeyDown", - "onKeyUp" + "onKeyUp", + "useSemanticHTML" ], this.state = { generation: 0 - }, this.selection = null, this.onEditorChange = (n, s, i, o) => { - var a, u; - n === "text-change" ? (a = this.onEditorChangeText) == null || a.call(this, this.editor.root.innerHTML, s, o, this.unprivilegedEditor) : n === "selection-change" && ((u = this.onEditorChangeSelection) == null || u.call(this, s, o, this.unprivilegedEditor)); + }, this.selection = null, this.onEditorChange = (e, n, s, i) => { + e === "text-change" ? this.onEditorChangeText?.( + this.props.useSemanticHTML !== !1 ? this.editor.getSemanticHTML() : this.editor.root.innerHTML, + n, + i, + this.unprivilegedEditor + ) : e === "selection-change" && this.onEditorChangeSelection?.(n, i, this.unprivilegedEditor); }; - const e = this.isControlled() ? t.value : t.defaultValue; - this.value = e ?? ""; + const t = this.isControlled() ? r.value : r.defaultValue; + this.value = t ?? ""; } - validateProps(t) { - if (Zt.Children.count(t.children) > 1) + validateProps(r) { + if (Te.Children.count(r.children) > 1) throw new Error("The Quill editing area can only be composed of a single React element."); - if (Zt.Children.count(t.children)) { - const e = Zt.Children.only(t.children); - if ((e == null ? void 0 : e.type) === "textarea") - throw new Error("Quill does not support editing on a