diff --git a/docs/frameworks/nextjs.md b/docs/frameworks/nextjs.md index 787bc62e..42f39fca 100644 --- a/docs/frameworks/nextjs.md +++ b/docs/frameworks/nextjs.md @@ -2,60 +2,35 @@ > > This document was generated by a neural network and may contain inaccuracies or mistakes. -# Next.js + Adminizer (кейс `nyachik-connect`) +# Next.js + Adminizer (`nyachik-connect` case) -## Область применения +## Scope -Этот документ восстановлен по реальным коммитам проекта: +This page summarizes a production integration of Adminizer into a Next.js application with Sequelize + SQLite. -- `36ea9f5` - `feat: add sequelize data layer and adminizer` -- `15d2c6f` - `test: ensure adminizer route prefix` +Source commits used for reconstruction: -Цель: показать, что именно было добавлено для рабочей интеграции Adminizer в Next.js с Sequelize + SQLite. +- `36ea9f5` — `feat: add sequelize data layer and adminizer` +- `15d2c6f` — `test: ensure adminizer route prefix` -## Что было сделано +## What was added -1. Добавлены зависимости для Adminizer + Sequelize + SQLite. -2. Добавлен `next.config.mjs` с server-runtime настройками для ORM/Adminizer. -3. Реализована singleton-инициализация Sequelize и регистрация моделей. -4. Добавлен singleton bootstrap для Adminizer (`src/server/adminizer/index.ts`). -5. Добавлен конфиг Adminizer (`src/server/adminizer/config.ts`) с описанием моделей для UI. -6. Добавлен catch-all API маршрут (`src/pages/api/adminizer/[[...adminizer]].ts`) с проксированием в middleware Adminizer. -7. Добавлен тест, фиксирующий `routePrefix = "/adminizer"`. +1. Installed Adminizer + Sequelize + SQLite dependencies. +2. Updated `next.config.mjs` for server runtime compatibility. +3. Added singleton Sequelize initialization and model registration. +4. Added singleton Adminizer bootstrap in `src/server/adminizer/index.ts`. +5. Added Adminizer UI config in `src/server/adminizer/config.ts`. +6. Added catch-all API route `src/pages/api/adminizer/[[...adminizer]].ts`. +7. Added a Vitest assertion for `routePrefix = "/adminizer"`. -## Изменения по файлам (коммит `36ea9f5`) - -- `.gitignore` -- `data/.gitkeep` -- `next.config.mjs` -- `package.json` -- `src/lib/models/service-category.ts` -- `src/lib/models/service-offer.ts` -- `src/lib/models/service-booking.ts` -- `src/lib/models/index.ts` -- `src/lib/sequelize.ts` -- `src/server/adminizer/config.ts` -- `src/server/adminizer/index.ts` -- `src/pages/api/adminizer/[[...adminizer]].ts` - -## Шаг 1. Установка зависимостей - -Используется тот же набор зависимостей, что и в интеграционном коммите: +## 1) Install dependencies ```bash npm install adminizer sequelize sqlite3 -npm install -D @types/sqlite3 +npm install -D @types/sqlite3 vitest ``` -Позже в ветке добавили тесты префикса маршрута через Vitest (`15d2c6f`): - -```bash -npm install -D vitest -``` - -## Шаг 2. Настройка Next.js runtime - -В интеграционном коммите использовался такой `next.config.mjs`: +## 2) Configure Next.js runtime ```js /** @type {import('next').NextConfig} */ @@ -73,1079 +48,67 @@ const nextConfig = { "./src/server/adminizer/**/*", ], }, - images: { - remotePatterns: [ - { - protocol: "https", - hostname: "images.unsplash.com", - }, - { - protocol: "https", - hostname: "firebasestorage.googleapis.com", - }, - { - protocol: "https", - hostname: "storage.googleapis.com", - }, - ], - }, }; export default nextConfig; ``` +Why this matters: -Важные детали этой конфигурации: - -- `serverComponentsExternalPackages` оставляет нативные/ORM пакеты внешними для server runtime. -- `outputFileTracingIncludes` гарантирует, что sqlite binary и серверный код adminizer попадут в билд. - -## Шаг 3. Подготовка слоя БД (Sequelize) - -### 3.1 `.gitignore` и каталог данных - -```gitignore -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? - -# Playwright MCP cache -.playwright-mcp - -# Documentation -docs -.next -# Database -data/*.sqlite -``` - - -`data/.gitkeep` добавлен, чтобы каталог базы данных оставался в git. - -### 3.2 Реестр Sequelize-моделей - -```ts -import { Sequelize } from "sequelize"; -import { ServiceCategory, initServiceCategory } from "./service-category"; -import { ServiceOffer, initServiceOffer } from "./service-offer"; -import { ServiceBooking, initServiceBooking } from "./service-booking"; - -export function initModels(sequelize: Sequelize) { - initServiceCategory(sequelize); - initServiceOffer(sequelize); - initServiceBooking(sequelize); - - ServiceCategory.hasMany(ServiceOffer, { - as: "offers", - foreignKey: "categoryId", - }); - - ServiceOffer.belongsTo(ServiceCategory, { - as: "category", - foreignKey: "categoryId", - }); +- `serverComponentsExternalPackages` avoids bundling issues for native ORM/runtime packages. +- `outputFileTracingIncludes` ensures SQLite native bindings and Adminizer server files are present in build output. - ServiceOffer.hasMany(ServiceBooking, { - as: "bookings", - foreignKey: "offerId", - }); +## 3) Add DB and Adminizer singletons - ServiceBooking.belongsTo(ServiceOffer, { - as: "offer", - foreignKey: "offerId", - }); +Use a singleton Sequelize connector and call `SequelizeAdapter.registerSystemModels(orm)` before `adminizer.init(...)`. - return { - ServiceCategory, - ServiceOffer, - ServiceBooking, - } as const; -} +Recommended shape: -export type Models = ReturnType; - -export { ServiceCategory, ServiceOffer, ServiceBooking }; -``` +- `src/lib/sequelize.ts`: creates and caches Sequelize instance. +- `src/lib/models/index.ts`: initializes custom models and relations. +- `src/server/adminizer/index.ts`: creates and caches Adminizer instance. +- `src/server/adminizer/config.ts`: stores `routePrefix`, model mapping, and UI field config. +## 4) Add catch-all API route -### 3.3 Модель `ServiceCategory` - -```ts -import { - CreationOptional, - DataTypes, - InferAttributes, - InferCreationAttributes, - Model, - Sequelize, -} from "sequelize"; - -export class ServiceCategory extends Model< - InferAttributes, - InferCreationAttributes -> { - declare id: CreationOptional; - declare name: string; - declare description: string | null; - declare slug: CreationOptional; - declare shortDescription: string; - declare createdAt: CreationOptional; - declare updatedAt: CreationOptional; - - get summary() { - const description = this.getDataValue("description"); - return description ? `${this.name}: ${description}` : this.name; - } -} - -export function initServiceCategory(sequelize: Sequelize) { - ServiceCategory.init( - { - id: { - type: DataTypes.INTEGER.UNSIGNED, - autoIncrement: true, - primaryKey: true, - }, - name: { - type: DataTypes.STRING(120), - allowNull: false, - unique: true, - }, - description: { - type: DataTypes.TEXT, - allowNull: true, - }, - slug: { - type: DataTypes.STRING(150), - allowNull: false, - unique: true, - }, - shortDescription: { - type: DataTypes.VIRTUAL, - get(this: ServiceCategory) { - const description = this.getDataValue("description"); - if (!description) return "Описание отсутствует"; - return description.length > 120 - ? `${description.slice(0, 117).trim()}...` - : description; - }, - }, - createdAt: DataTypes.DATE, - updatedAt: DataTypes.DATE, - }, - { - tableName: "service_categories", - modelName: "ServiceCategory", - sequelize, - hooks: { - beforeValidate(category) { - if (!category.slug && category.name) { - category.slug = category.name - .trim() - .toLowerCase() - .replace(/[^a-z0-9\u0400-\u04FF]+/g, "-") - .replace(/^-+|-+$/g, ""); - } - }, - }, - defaultScope: { - order: [["name", "ASC"]], - }, - } - ); - - return ServiceCategory; -} -``` - - -### 3.4 Модель `ServiceOffer` - -```ts -import { - CreationOptional, - DataTypes, - InferAttributes, - InferCreationAttributes, - Model, - NonAttribute, - Sequelize, -} from "sequelize"; -import { ServiceCategory } from "./service-category"; - -export type OfferStatus = "draft" | "published" | "archived"; - -export class ServiceOffer extends Model< - InferAttributes, - InferCreationAttributes -> { - declare id: CreationOptional; - declare title: string; - declare summary: string; - declare description: string | null; - declare price: number; - declare currency: string; - declare durationMinutes: number | null; - declare providerName: string; - declare providerEmail: string | null; - declare providerPhone: string | null; - declare meetingPoint: string | null; - declare status: OfferStatus; - declare isFeatured: CreationOptional; - declare isActive: CreationOptional; - declare categoryId: number; - declare category?: NonAttribute; - declare createdAt: CreationOptional; - declare updatedAt: CreationOptional; - declare displayPrice: string; - declare providerContact: string; -} - -export function initServiceOffer(sequelize: Sequelize) { - ServiceOffer.init( - { - id: { - type: DataTypes.INTEGER.UNSIGNED, - autoIncrement: true, - primaryKey: true, - }, - title: { - type: DataTypes.STRING(180), - allowNull: false, - }, - summary: { - type: DataTypes.STRING(280), - allowNull: false, - }, - description: { - type: DataTypes.TEXT, - allowNull: true, - }, - price: { - type: DataTypes.INTEGER.UNSIGNED, - allowNull: false, - }, - currency: { - type: DataTypes.STRING(3), - allowNull: false, - defaultValue: "VND", - }, - durationMinutes: { - type: DataTypes.INTEGER, - allowNull: true, - }, - providerName: { - type: DataTypes.STRING(120), - allowNull: false, - }, - providerEmail: { - type: DataTypes.STRING(160), - allowNull: true, - validate: { - isEmail: true, - }, - }, - providerPhone: { - type: DataTypes.STRING(40), - allowNull: true, - }, - meetingPoint: { - type: DataTypes.STRING(160), - allowNull: true, - }, - status: { - type: DataTypes.ENUM("draft", "published", "archived"), - allowNull: false, - defaultValue: "published", - }, - isFeatured: { - type: DataTypes.BOOLEAN, - allowNull: false, - defaultValue: false, - }, - isActive: { - type: DataTypes.BOOLEAN, - allowNull: false, - defaultValue: true, - }, - categoryId: { - type: DataTypes.INTEGER.UNSIGNED, - allowNull: false, - references: { - model: ServiceCategory, - key: "id", - }, - onDelete: "CASCADE", - }, - displayPrice: { - type: DataTypes.VIRTUAL, - get(this: ServiceOffer) { - const price = this.getDataValue("price"); - const currency = this.getDataValue("currency"); - return new Intl.NumberFormat("ru-RU", { - style: "currency", - currency, - maximumFractionDigits: 0, - }).format(price ?? 0); - }, - }, - providerContact: { - type: DataTypes.VIRTUAL, - get(this: ServiceOffer) { - const email = this.getDataValue("providerEmail"); - const phone = this.getDataValue("providerPhone"); - if (email && phone) return `${email} / ${phone}`; - return email ?? phone ?? "Контакты не указаны"; - }, - }, - createdAt: DataTypes.DATE, - updatedAt: DataTypes.DATE, - }, - { - tableName: "service_offers", - modelName: "ServiceOffer", - sequelize, - defaultScope: { - order: [["createdAt", "DESC"]], - }, - } - ); - - return ServiceOffer; -} -``` - - -### 3.5 Модель `ServiceBooking` - -```ts -import { - CreationOptional, - DataTypes, - InferAttributes, - InferCreationAttributes, - Model, - NonAttribute, - Sequelize, -} from "sequelize"; -import { ServiceOffer } from "./service-offer"; - -export type BookingStatus = "pending" | "confirmed" | "completed" | "cancelled"; - -export class ServiceBooking extends Model< - InferAttributes, - InferCreationAttributes -> { - declare id: CreationOptional; - declare customerName: string; - declare customerEmail: string; - declare customerPhone: string | null; - declare attendees: number; - declare scheduledFor: Date; - declare status: BookingStatus; - declare basePrice: number; - declare serviceFee: number; - declare currency: string; - declare specialRequests: string | null; - declare offerId: number; - declare offer?: NonAttribute; - declare createdAt: CreationOptional; - declare updatedAt: CreationOptional; - declare totalPrice: number; - declare statusLabel: string; - declare formattedSchedule: string; -} - -const STATUS_LABELS: Record = { - pending: "Ожидает подтверждения", - confirmed: "Подтверждено", - completed: "Завершено", - cancelled: "Отменено", -}; - -export function initServiceBooking(sequelize: Sequelize) { - ServiceBooking.init( - { - id: { - type: DataTypes.INTEGER.UNSIGNED, - autoIncrement: true, - primaryKey: true, - }, - customerName: { - type: DataTypes.STRING(160), - allowNull: false, - }, - customerEmail: { - type: DataTypes.STRING(160), - allowNull: false, - validate: { - isEmail: true, - }, - }, - customerPhone: { - type: DataTypes.STRING(40), - allowNull: true, - }, - attendees: { - type: DataTypes.INTEGER.UNSIGNED, - allowNull: false, - defaultValue: 1, - }, - scheduledFor: { - type: DataTypes.DATE, - allowNull: false, - }, - status: { - type: DataTypes.ENUM("pending", "confirmed", "completed", "cancelled"), - allowNull: false, - defaultValue: "pending", - }, - basePrice: { - type: DataTypes.INTEGER.UNSIGNED, - allowNull: false, - defaultValue: 0, - }, - serviceFee: { - type: DataTypes.INTEGER.UNSIGNED, - allowNull: false, - defaultValue: 0, - }, - currency: { - type: DataTypes.STRING(3), - allowNull: false, - defaultValue: "VND", - }, - specialRequests: { - type: DataTypes.TEXT, - allowNull: true, - }, - offerId: { - type: DataTypes.INTEGER.UNSIGNED, - allowNull: false, - references: { - model: ServiceOffer, - key: "id", - }, - onDelete: "CASCADE", - }, - totalPrice: { - type: DataTypes.VIRTUAL, - get(this: ServiceBooking) { - const base = this.getDataValue("basePrice") ?? 0; - const fee = this.getDataValue("serviceFee") ?? 0; - return base + fee; - }, - }, - statusLabel: { - type: DataTypes.VIRTUAL, - get(this: ServiceBooking) { - const status = this.getDataValue("status") as BookingStatus; - return STATUS_LABELS[status] ?? status; - }, - }, - formattedSchedule: { - type: DataTypes.VIRTUAL, - get(this: ServiceBooking) { - const date = this.getDataValue("scheduledFor"); - if (!date) return "Дата не выбрана"; - return new Intl.DateTimeFormat("ru-RU", { - dateStyle: "long", - timeStyle: "short", - }).format(date); - }, - }, - createdAt: DataTypes.DATE, - updatedAt: DataTypes.DATE, - }, - { - tableName: "service_bookings", - modelName: "ServiceBooking", - sequelize, - defaultScope: { - order: [["scheduledFor", "ASC"]], - }, - } - ); - - return ServiceBooking; -} -``` - - -### 3.6 Singleton/bootstrap Sequelize - -```ts -import { Sequelize } from "sequelize"; -import { initModels, Models } from "./models"; - -const globalForSequelize = globalThis as unknown as { - __sequelize?: Sequelize; - __sequelizeModels?: Models; - __sequelizeSyncPromise?: Promise; -}; - -const databaseUrl = process.env.DATABASE_URL ?? "sqlite:./data/community.sqlite"; - -const sequelizeInstance = - globalForSequelize.__sequelize ?? - new Sequelize(databaseUrl, { - logging: process.env.NODE_ENV === "development" ? console.log : false, - }); - -const modelsInstance = globalForSequelize.__sequelizeModels ?? initModels(sequelizeInstance); - -if (!globalForSequelize.__sequelize) { - globalForSequelize.__sequelize = sequelizeInstance; - globalForSequelize.__sequelizeModels = modelsInstance; -} - -export const sequelize = sequelizeInstance; -export const models = modelsInstance; - -export async function ensureDatabase() { - if (process.env.NODE_ENV !== "production" && process.env.SKIP_DB_SYNC !== "true") { - if (!globalForSequelize.__sequelizeSyncPromise) { - globalForSequelize.__sequelizeSyncPromise = sequelize.sync(); - } - await globalForSequelize.__sequelizeSyncPromise; - } -} -``` - - -## Шаг 4. Инициализация Adminizer - -### 4.1 Конфиг Adminizer (route, auth, models, fields) - -```ts -import type { AdminpanelConfig } from "adminizer"; - -export const adminizerConfig: AdminpanelConfig = { - routePrefix: "/adminizer", - system: { - defaultORM: "sequelize", - }, - auth: { - enable: true, - captcha: false, - description: "Войдите, чтобы управлять данными сообщества", - }, - administrator: { - login: process.env.ADMINIZER_LOGIN ?? "community", - password: process.env.ADMINIZER_PASSWORD ?? "connect123", - }, - translation: { - locales: ["ru", "en"], - defaultLocale: "ru", - path: "translations", - }, - welcome: { - title: "Панель сообщества Нячанг", - text: "Управляйте услугами, бронированиями и категориями", - }, - navbar: { - additionalLinks: [ - { - id: "back-to-site", - title: "На сайт", - link: "/", - type: "self", - icon: "home", - }, - { - id: "checkout", - title: "Форма заявки", - link: "/checkout", - type: "self", - icon: "assignment", - }, - ], - }, - models: { - ServiceCategory: { - title: "Категории услуг", - model: "ServiceCategory", - icon: "category", - list: { - fields: { - id: true, - name: true, - slug: { - title: "Слаг", - visible: true, - }, - shortDescription: { - title: "Краткое описание", - visible: true, - }, - }, - defaultSort: { - field: "name", - order: "ASC", - }, - }, - fields: { - id: { - title: "ID", - disabled: true, - }, - name: { - title: "Название", - required: true, - }, - slug: { - title: "Слаг", - description: "Генерируется автоматически, но можно задать вручную", - }, - description: { - title: "Описание", - type: "textarea", - }, - createdAt: { - title: "Создано", - type: "datetime", - disabled: true, - }, - updatedAt: { - title: "Обновлено", - type: "datetime", - disabled: true, - }, - }, - create: { - fields: { - slug: false, - createdAt: false, - updatedAt: false, - }, - }, - edit: { - fields: { - createdAt: false, - updatedAt: false, - }, - }, - }, - ServiceOffer: { - title: "Услуги", - model: "ServiceOffer", - icon: "miscellaneous_services", - list: { - fields: { - title: true, - status: { - title: "Статус", - visible: true, - }, - displayPrice: { - title: "Стоимость", - visible: true, - }, - providerName: { - title: "Исполнитель", - visible: true, - }, - category: { - title: "Категория", - visible: true, - display: (record) => record.category?.name, - }, - }, - filters: { - status: { - title: "Статус", - type: "select", - options: ["draft", "published", "archived"], - }, - isActive: { - title: "Активна", - type: "boolean", - }, - }, - defaultSort: { - field: "createdAt", - order: "DESC", - }, - }, - fields: { - id: { - title: "ID", - disabled: true, - }, - title: { - title: "Название", - required: true, - }, - summary: { - title: "Краткое описание", - type: "textarea", - required: true, - }, - description: { - title: "Полное описание", - type: "textarea", - }, - categoryId: { - title: "Категория", - type: "relation", - relation: { - model: "ServiceCategory", - displayField: "name", - searchField: "name", - }, - required: true, - }, - price: { - title: "Цена (VND)", - type: "number", - description: "Указывается за одного участника", - }, - currency: { - title: "Валюта", - type: "select", - isIn: ["VND", "USD"], - defaultValue: "VND", - }, - durationMinutes: { - title: "Длительность (мин)", - type: "number", - }, - providerName: { - title: "Исполнитель", - required: true, - }, - providerEmail: { - title: "Email исполнителя", - type: "email", - }, - providerPhone: { - title: "Телефон исполнителя", - }, - meetingPoint: { - title: "Локация встречи", - }, - status: { - title: "Статус", - type: "select", - isIn: ["draft", "published", "archived"], - enum: { - draft: "Черновик", - published: "Опубликовано", - archived: "Архив", - }, - defaultValue: "published", - }, - isFeatured: { - title: "В подборке", - type: "boolean", - defaultValue: false, - }, - isActive: { - title: "Активна", - type: "boolean", - defaultValue: true, - }, - createdAt: { - title: "Создано", - type: "datetime", - disabled: true, - }, - updatedAt: { - title: "Обновлено", - type: "datetime", - disabled: true, - }, - }, - create: { - fields: { - createdAt: false, - updatedAt: false, - }, - }, - edit: { - fields: { - createdAt: false, - updatedAt: false, - }, - }, - }, - ServiceBooking: { - title: "Заявки", - model: "ServiceBooking", - icon: "event_available", - list: { - fields: { - customerName: { - title: "Клиент", - visible: true, - }, - statusLabel: { - title: "Статус", - visible: true, - }, - scheduledFor: { - title: "Дата", - visible: true, - }, - totalPrice: { - title: "Сумма", - visible: true, - }, - }, - filters: { - status: { - title: "Статус", - type: "select", - options: ["pending", "confirmed", "completed", "cancelled"], - }, - }, - defaultSort: { - field: "scheduledFor", - order: "DESC", - }, - }, - fields: { - id: { - title: "ID", - disabled: true, - }, - customerName: { - title: "Имя клиента", - required: true, - }, - customerEmail: { - title: "Email", - type: "email", - required: true, - }, - customerPhone: { - title: "Телефон", - }, - attendees: { - title: "Участники", - type: "number", - }, - scheduledFor: { - title: "Назначено на", - type: "datetime", - }, - status: { - title: "Статус", - type: "select", - isIn: ["pending", "confirmed", "completed", "cancelled"], - enum: { - pending: "Ожидает", - confirmed: "Подтверждено", - completed: "Завершено", - cancelled: "Отменено", - }, - }, - specialRequests: { - title: "Пожелания", - type: "textarea", - }, - offerId: { - title: "Услуга", - type: "relation", - relation: { - model: "ServiceOffer", - displayField: "title", - searchField: "title", - }, - }, - basePrice: { - title: "Базовая сумма", - type: "number", - }, - serviceFee: { - title: "Комиссия", - type: "number", - }, - currency: { - title: "Валюта", - }, - createdAt: { - title: "Создано", - type: "datetime", - disabled: true, - }, - updatedAt: { - title: "Обновлено", - type: "datetime", - disabled: true, - }, - }, - edit: { - fields: { - createdAt: false, - updatedAt: false, - }, - }, - }, - }, -}; -``` - - -### 4.2 Singleton-инициализация Adminizer - -```ts -import { Adminizer, SequelizeAdapter } from "adminizer"; -import { adminizerConfig } from "./config"; -import { sequelize } from "@/lib/sequelize"; - -const globalForAdminizer = globalThis as unknown as { - __adminizerPromise?: Promise; -}; - -export async function getAdminizer(): Promise { - if (!globalForAdminizer.__adminizerPromise) { - globalForAdminizer.__adminizerPromise = (async () => { - const adapter = new SequelizeAdapter(sequelize); - const adminizer = new Adminizer([adapter]); - await adminizer.init(adminizerConfig); - return adminizer; - })(); - } - return globalForAdminizer.__adminizerPromise; -} - -export async function getAdminizerMiddleware() { - const adminizer = await getAdminizer(); - return adminizer.getMiddleware(); -} -``` - - -Почему здесь нужен singleton: - -- В Next.js dev-режиме и serverless-подобном исполнении модули могут инициализироваться повторно. -- Кэш через `globalThis` защищает от повторных `adminizer.init(...)` и дублирующего bootstrap адаптера. - -## Шаг 5. Проксирование запросов Next.js в Adminizer middleware - -Создаётся catch-all API маршрут: +Create `src/pages/api/adminizer/[[...adminizer]].ts` and proxy requests to Adminizer middleware: ```ts import type { NextApiRequest, NextApiResponse } from "next"; import { getAdminizerMiddleware } from "@/server/adminizer"; -export const config = { - api: { - bodyParser: false, - externalResolver: true, - }, -}; - export default async function handler(req: NextApiRequest, res: NextApiResponse) { - try { - const middleware = await getAdminizerMiddleware(); - await new Promise((resolve, reject) => { - const next = (err?: unknown) => { - if (err) reject(err); - else resolve(); - }; - middleware(req, res, next); + const middleware = await getAdminizerMiddleware(); + + await new Promise((resolve, reject) => { + middleware(req as any, res as any, (err?: unknown) => { + if (err) return reject(err); + resolve(); }); - } catch (error) { - console.error("Adminizer error:", error); - res.status(500).json({ message: "Админ-панель недоступна" }); - } + }); } ``` - -Этот маршрут делает две критичные вещи: - -- Отключает встроенный body parser (`bodyParser: false`), чтобы Adminizer middleware сам обработал тело запроса. -- Оборачивает callback middleware в Promise, чтобы Next API handler корректно дожидался завершения. - -## Шаг 6. Тест на префикс маршрута - -Добавлено в коммите `15d2c6f`: +## 5) Add a route prefix test ```ts -import { describe, it, expect } from "vitest"; -import { adminizerConfig } from "./config"; +import { describe, expect, test } from "vitest"; +import { adminizerConfig } from "@/server/adminizer/config"; -describe("adminizer configuration", () => { - it("указывает корректный префикс маршрута админ-панели", () => { +describe("adminizer route prefix", () => { + test("should be /adminizer", () => { expect(adminizerConfig.routePrefix).toBe("/adminizer"); }); }); ``` +## Verification checklist -В `package.json` этого же коммита добавлен test-скрипт: - -```json -{ - "scripts": { - "test": "vitest run" - } -} -``` - -## Шаг 7. Переменные окружения в интеграции - -```bash -# SQLite db path -DATABASE_URL=sqlite:./data/community.sqlite - -# Adminizer credentials -ADMINIZER_LOGIN=community -ADMINIZER_PASSWORD=connect123 - -# Optional: skip sync in dev if needed -SKIP_DB_SYNC=true -``` - -## Шаг 8. Проверка работы - -```bash -npm install -npm run dev -``` - -После запуска проверить: - -1. Открыть настроенный префикс панели (`/adminizer` в этом кейсе). -2. Убедиться, что появилась форма входа. -3. Войти под `ADMINIZER_LOGIN` / `ADMINIZER_PASSWORD`. -4. Проверить, что видны модели `ServiceCategory`, `ServiceOffer`, `ServiceBooking`. -5. Создать/обновить записи и проверить, что в терминале нет ошибок сервера. - -## Нюанс именно этого коммита - -Префикс маршрута явно зафиксирован как `/adminizer` (и покрыт тестом). Транспортный маршрут реализован через `src/pages/api/adminizer/[[...adminizer]].ts`. При переносе в другой проект обязательно синхронизируйте внешний URL панели и внутреннюю маршрутизацию Next.js. +- Start app in dev mode and open `/adminizer`. +- Ensure list pages load and CRUD works for a sample model. +- Build production bundle and run it. +- Run route prefix test. -## Быстрый план внедрения (минимальный порядок) +## Related guide -1. Установить `adminizer`, `sequelize`, `sqlite3`. -2. Добавить `next.config.mjs` с runtime/tracing настройками. -3. Добавить Sequelize-модели и singleton `src/lib/sequelize.ts`. -4. Добавить `src/server/adminizer/config.ts` и `src/server/adminizer/index.ts`. -5. Добавить catch-all API маршрут `src/pages/api/adminizer/[[...adminizer]].ts`. -6. Добавить тест на `routePrefix`. -7. Запустить приложение и проверить вход + CRUD в Adminizer. +- [Nuxt 3 Framework Guide](./nuxt3.md) diff --git a/docs/frameworks/nuxt3.md b/docs/frameworks/nuxt3.md new file mode 100644 index 00000000..cdf4dc2c --- /dev/null +++ b/docs/frameworks/nuxt3.md @@ -0,0 +1,290 @@ +> ⚠️ **AI-DRAFT** +> +> This document was generated by a neural network and may contain inaccuracies or mistakes. + +# Nuxt 3 + Adminizer (minimal Sequelize fixture) + +This page is a **detailed, copy-paste friendly integration guide** for connecting Adminizer to Nuxt 3 with Sequelize + SQLite. + +Reference working fixture: `fixture-nuxt3/`. + +--- + +## 1) Install dependencies + +```bash +npm install adminizer nuxt sequelize sqlite3 +npm install -D tsx typescript +``` + +Quick environment check (required by this repository workflow): + +```bash +npx tsx --version +``` + +--- + +## 2) Create project files + +### `package.json` + +```json +{ + "name": "fixture-nuxt3", + "private": true, + "type": "module", + "scripts": { + "dev": "nuxi dev", + "build": "nuxi build", + "preview": "nuxi preview" + }, + "dependencies": { + "adminizer": "^4.4.0", + "nuxt": "^3.13.2", + "sequelize": "^6.37.7", + "sqlite3": "^5.1.7" + }, + "devDependencies": { + "tsx": "^4.19.3", + "typescript": "^5.7.2" + } +} +``` + +### `nuxt.config.ts` + +```ts +export default defineNuxtConfig({ + compatibilityDate: "2025-01-01", + devtools: { enabled: true } +}); +``` + +### `tsconfig.json` + +```json +{ + "extends": "./.nuxt/tsconfig.json" +} +``` + +### `app.vue` + +```vue + +``` + +--- + +## 3) Create a minimal Sequelize model + +### `server/models/Post.ts` + +```ts +import { DataTypes, Model, type InferAttributes, type InferCreationAttributes, type Sequelize } from "sequelize"; + +export class Post extends Model, InferCreationAttributes> { + declare id: number; + declare title: string; + declare content: string | null; + declare createdAt: Date; + declare updatedAt: Date; +} + +export function initPostModel(sequelize: Sequelize) { + Post.init( + { + id: { + type: DataTypes.INTEGER.UNSIGNED, + primaryKey: true, + autoIncrement: true + }, + title: { + type: DataTypes.STRING(180), + allowNull: false + }, + content: { + type: DataTypes.TEXT, + allowNull: true + }, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE + }, + { + sequelize, + tableName: "posts", + modelName: "Post" + } + ); + + return Post; +} +``` + +--- + +## 4) Bootstrap Sequelize (singleton) + +### `server/utils/sequelize.ts` + +```ts +import { Sequelize } from "sequelize"; +import { SequelizeAdapter } from "adminizer"; +import { initPostModel } from "../models/Post"; + +let sequelizeInstance: Sequelize | null = null; + +export async function getSequelizeInstance() { + if (sequelizeInstance) { + return sequelizeInstance; + } + + sequelizeInstance = new Sequelize({ + dialect: "sqlite", + storage: ".tmp/adminizer-nuxt3.sqlite", + logging: false + }); + + await SequelizeAdapter.registerSystemModels(sequelizeInstance as any); + initPostModel(sequelizeInstance); + await sequelizeInstance.sync(); + + return sequelizeInstance; +} +``` + +What this does: +- creates one DB connection for the app, +- registers Adminizer system tables, +- registers your `Post` model, +- creates tables automatically via `sync()`. + +--- + +## 5) Bootstrap Adminizer middleware (singleton) + +### `server/utils/adminizer.ts` + +```ts +import type { IncomingMessage, ServerResponse } from "node:http"; +import { Adminizer, SequelizeAdapter } from "adminizer"; +import { getSequelizeInstance } from "./sequelize"; + +const adminizerConfig = { + routePrefix: "/adminizer", + auth: { + enable: false + }, + models: { + Post: { + title: "Posts", + model: "post", + displayName: "title", + fields: { + title: { type: "string", required: true }, + content: { type: "text" }, + createdAt: false, + updatedAt: false + } + } + } +}; + +let middlewareInstance: + | ((req: IncomingMessage, res: ServerResponse, next: (error?: unknown) => void) => void) + | null = null; + +export async function getAdminizerMiddleware() { + if (middlewareInstance) { + return middlewareInstance; + } + + const sequelize = await getSequelizeInstance(); + const adapter = new SequelizeAdapter(sequelize as any); + const adminizer = new Adminizer([adapter]); + + await adminizer.init(adminizerConfig as any); + middlewareInstance = adminizer.getMiddleware(); + + return middlewareInstance; +} +``` + +--- + +## 6) Connect Adminizer to Nuxt server middleware + +### `server/middleware/adminizer.ts` + +```ts +import { fromNodeMiddleware } from "h3"; +import { getAdminizerMiddleware } from "../utils/adminizer"; + +export default fromNodeMiddleware(async (req, res, next) => { + const url = req.url ?? ""; + + if (!url.startsWith("/adminizer")) { + return next(); + } + + const middleware = await getAdminizerMiddleware(); + middleware(req, res, next); +}); +``` + +This keeps Nuxt routes intact and sends only `/adminizer*` requests to Adminizer. + +--- + +## 7) Run and validate + +```bash +npm install +npx tsx --version +npm run dev +``` + +Open: +- `http://localhost:3000/` +- `http://localhost:3000/adminizer` + +Expected result: `/adminizer` should return HTTP 200 and Adminizer HTML. + +--- + +## 8) Build and preview + +```bash +npm run build +npm run preview +``` + +--- + +## Agent checklist (recommended) + +When an AI agent integrates Adminizer into Nuxt 3, follow this sequence: + +1. Install dependencies (`adminizer`, `nuxt`, `sequelize`, `sqlite3`, `tsx`, `typescript`). +2. Verify TSX availability (`npx tsx --version`). +3. Implement model(s) with `Model.init` (avoid decorator-only setup to reduce Nitro transform issues). +4. Create Sequelize singleton and call `SequelizeAdapter.registerSystemModels`. +5. Create Adminizer singleton and `await adminizer.init(config)`. +6. Mount Adminizer via Nuxt server middleware for `/adminizer*`. +7. Run dev server and verify `200` on `/adminizer`. +8. Run production build and preview. + +--- + +## Notes + +- This fixture is intentionally minimal and focused on wiring. +- It uses `Adminizer` and `SequelizeAdapter` from the `adminizer` package root export. +- For production use, enable Adminizer auth and move secrets to environment variables. +- Add more Sequelize models in `server/models` and extend `models` config in `server/utils/adminizer.ts`. diff --git a/docs/index.md b/docs/index.md index 2e71c1f0..6813a1cb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,6 +26,7 @@ * [Form Error Management](FormError.md) * [Appearance](Appearance.md) * [Next.js Framework Guide](frameworks/nextjs.md) +* [Nuxt 3 Framework Guide](frameworks/nuxt3.md) ## 4. Admin Panel Features diff --git a/fixture-nuxt3/README.md b/fixture-nuxt3/README.md new file mode 100644 index 00000000..7cad7cd8 --- /dev/null +++ b/fixture-nuxt3/README.md @@ -0,0 +1,20 @@ +# fixture-nuxt3 + +Minimal Nuxt 3 + Adminizer example with Sequelize and SQLite. + +## Run + +```bash +npm install +npx tsx --version +npm run dev +``` + +Open http://localhost:3000/adminizer. + +## Build + +```bash +npm run build +npm run preview +``` diff --git a/fixture-nuxt3/app.vue b/fixture-nuxt3/app.vue new file mode 100644 index 00000000..dca93f88 --- /dev/null +++ b/fixture-nuxt3/app.vue @@ -0,0 +1,6 @@ + diff --git a/fixture-nuxt3/nuxt.config.ts b/fixture-nuxt3/nuxt.config.ts new file mode 100644 index 00000000..c16b8ef3 --- /dev/null +++ b/fixture-nuxt3/nuxt.config.ts @@ -0,0 +1,4 @@ +export default defineNuxtConfig({ + compatibilityDate: "2025-01-01", + devtools: { enabled: true } +}); diff --git a/fixture-nuxt3/package.json b/fixture-nuxt3/package.json new file mode 100644 index 00000000..67ab4f60 --- /dev/null +++ b/fixture-nuxt3/package.json @@ -0,0 +1,20 @@ +{ + "name": "fixture-nuxt3", + "private": true, + "type": "module", + "scripts": { + "dev": "nuxi dev", + "build": "nuxi build", + "preview": "nuxi preview" + }, + "dependencies": { + "adminizer": "^4.4.0", + "nuxt": "^3.13.2", + "sequelize": "^6.37.7", + "sqlite3": "^5.1.7" + }, + "devDependencies": { + "tsx": "^4.19.3", + "typescript": "^5.7.2" + } +} diff --git a/fixture-nuxt3/server/middleware/adminizer.ts b/fixture-nuxt3/server/middleware/adminizer.ts new file mode 100644 index 00000000..b53112d7 --- /dev/null +++ b/fixture-nuxt3/server/middleware/adminizer.ts @@ -0,0 +1,12 @@ +import { fromNodeMiddleware } from "h3"; +import { getAdminizerMiddleware } from "../utils/adminizer"; + +export default fromNodeMiddleware(async (req, res, next) => { + const url = req.url ?? ""; + if (!url.startsWith("/adminizer")) { + return next(); + } + + const middleware = await getAdminizerMiddleware(); + middleware(req, res, next); +}); diff --git a/fixture-nuxt3/server/models/Post.ts b/fixture-nuxt3/server/models/Post.ts new file mode 100644 index 00000000..ff7855e0 --- /dev/null +++ b/fixture-nuxt3/server/models/Post.ts @@ -0,0 +1,38 @@ +import { DataTypes, Model, type InferAttributes, type InferCreationAttributes, type Sequelize } from "sequelize"; + +export class Post extends Model, InferCreationAttributes> { + declare id: number; + declare title: string; + declare content: string | null; + declare createdAt: Date; + declare updatedAt: Date; +} + +export function initPostModel(sequelize: Sequelize) { + Post.init( + { + id: { + type: DataTypes.INTEGER.UNSIGNED, + primaryKey: true, + autoIncrement: true + }, + title: { + type: DataTypes.STRING(180), + allowNull: false + }, + content: { + type: DataTypes.TEXT, + allowNull: true + }, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE + }, + { + sequelize, + tableName: "posts", + modelName: "Post" + } + ); + + return Post; +} diff --git a/fixture-nuxt3/server/utils/adminizer.ts b/fixture-nuxt3/server/utils/adminizer.ts new file mode 100644 index 00000000..30b6463b --- /dev/null +++ b/fixture-nuxt3/server/utils/adminizer.ts @@ -0,0 +1,42 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { Adminizer, SequelizeAdapter } from "adminizer"; +import { getSequelizeInstance } from "./sequelize"; + +const adminizerConfig = { + routePrefix: "/adminizer", + auth: { + enable: false + }, + models: { + Post: { + title: "Posts", + model: "post", + displayName: "title", + fields: { + title: { type: "string", required: true }, + content: { type: "text" }, + createdAt: false, + updatedAt: false + } + } + } +}; + +let middlewareInstance: + | ((req: IncomingMessage, res: ServerResponse, next: (error?: unknown) => void) => void) + | null = null; + +export async function getAdminizerMiddleware() { + if (middlewareInstance) { + return middlewareInstance; + } + + const sequelize = await getSequelizeInstance(); + const adapter = new SequelizeAdapter(sequelize as any); + const adminizer = new Adminizer([adapter]); + + await adminizer.init(adminizerConfig as any); + middlewareInstance = adminizer.getMiddleware(); + + return middlewareInstance; +} diff --git a/fixture-nuxt3/server/utils/sequelize.ts b/fixture-nuxt3/server/utils/sequelize.ts new file mode 100644 index 00000000..57dd4df8 --- /dev/null +++ b/fixture-nuxt3/server/utils/sequelize.ts @@ -0,0 +1,23 @@ +import { Sequelize } from "sequelize"; +import { SequelizeAdapter } from "adminizer"; +import { initPostModel } from "../models/Post"; + +let sequelizeInstance: Sequelize | null = null; + +export async function getSequelizeInstance() { + if (sequelizeInstance) { + return sequelizeInstance; + } + + sequelizeInstance = new Sequelize({ + dialect: "sqlite", + storage: ".tmp/adminizer-nuxt3.sqlite", + logging: false + }); + + await SequelizeAdapter.registerSystemModels(sequelizeInstance as any); + initPostModel(sequelizeInstance); + await sequelizeInstance.sync(); + + return sequelizeInstance; +} diff --git a/fixture-nuxt3/tsconfig.json b/fixture-nuxt3/tsconfig.json new file mode 100644 index 00000000..4b34df15 --- /dev/null +++ b/fixture-nuxt3/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.nuxt/tsconfig.json" +}