rdy-admin
AdminKit — переиспользуемая админка для Next.js (в стиле FilamentPHP)
Декларативный набор: задаёшь схемы форм/таблиц и ресурсы — вёрстку писать не нужно.
Есть провайдеры авторизации и главный конфиг-провайдер с backendUrl.
Дизайн: тёмный сайдбар (Workly) + светлый контент с таблицей (bringova).
Запуск
npm install
npm run dev # http://localhost:3002Демо открывается в режиме demo: true (данные в памяти, без бэка). Вход — любой email/пароль.
/login— готовый экран входа (<LoginScreen />)/products— целая CRUD-страница из одного компонента<ResourcePage>(без вёрстки)/components— витринаSchemaFormиSchemaTable
1. Главный провайдер — AdminKitProvider
Хранит центральную информацию (backendUrl и т.д.) и поднимает авторизацию.
src/app/providers.tsx:
import { AdminKitProvider, AdminKitConfig, jwtAuth } from '@/adminkit';
const config: AdminKitConfig = {
appName: 'AdminKit',
brand: 'AdminKit',
backendUrl: process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:9000/api',
navigation: [
{ href: '/products', label: 'Products', icon: 'box' },
{ href: '/components', label: 'Components', icon: 'grid' },
],
auth: jwtAuth(), // провайдер авторизации (см. ниже)
demo: false, // false -> ресурсы ходят в backendUrl
};
export function Providers({ children }) {
return <AdminKitProvider config={config}>{children}</AdminKitProvider>;
}Доступ из любого места: const { config, api } = useAdminKit();
2. Провайдеры авторизации
Авторизация — это сменная стратегия AuthBackend:
interface AuthBackend {
login(api, { email, password }): Promise<{ token, user }>;
me(api): Promise<AuthUser>;
}В комплекте:
jwtAuth({ loginPath?, mePath?, tokenField? })— под твой NestJS (/auth/login→{accessToken,user},/auth/me).demoAuth(user?)— заглушка без бэка.- Можно написать свой (OAuth, сессии и т.п.) — просто объект с
login/me.
Хелперы:
const { user, login, logout, loading } = useAuth(); // состояние/действия
<RequireAuth role="admin">…</RequireAuth> // гард страницы
<LoginScreen role="admin" redirectTo="/products" /> // готовый экран входа3. Ресурс — CRUD из коробки (как Filament Resource)
Связываешь модель → схему таблицы и формы → эндпоинт. src/resources/products.ts:
export const productResource = createResource<Product>({
label: 'Products', singular: 'товар', endpoint: '/products',
columns: () => [
TextColumn.make('name').label('Название').sortable().searchable().weight('bold'),
TextColumn.make('price').label('Цена').money('RUB').sortable(),
BadgeColumn.make('status').colors({ active: 'green', draft: 'amber', out: 'red' })
.labels({ active: 'Активен', draft: 'Черновик', out: 'Нет в наличии' }),
],
form: () => [
TextInput.make('name').label('Название').required(),
Select.make('category').required().options({ '...': '...' }),
NumberInput.make('price').label('Цена, ₽').required().min(0),
Toggle.make('published').default(true),
FileUpload.make('images').multiple().image().maxFiles(6), // ← мультизагрузка
],
demoData: [ /* для demo:true */ ],
});И вся страница со списком, поиском, сортировкой, пагинацией, модалкой создания/редактирования и удалением — это:
// src/app/products/page.tsx
export default function ProductsPage() {
return (
<RequireAuth role="admin">
<ResourcePage resource={productResource} />
</RequireAuth>
);
}ResourcePage сам ходит в backendUrl + endpoint (GET список, POST/PATCH/DELETE),
а при demo:true работает на demoData в памяти.
4. Schema-компоненты отдельно
Если нужен не весь ресурс, а только форма или таблица:
<SchemaForm
schema={[ TextInput.make('name').required(), FileUpload.make('imgs').multiple().image() ]}
onSubmit={(values) => …}
/>
<SchemaTable
columns={[ TextColumn.make('name').sortable().searchable(), BadgeColumn.make('status').colors({…}) ]}
data={rows} rowKey={r => r.id}
actions={[ Action.make('msg').label('Сообщение').icon('chat').action(r => …) ]}
/>Доступные билдеры
Поля формы (@/adminkit): TextInput, Textarea, NumberInput, Select, Toggle,
Checkbox, DatePicker, TagsInput, FileUpload.
Общие методы: .label() .required() .placeholder() .hint() .default() .columnSpan(1|2) .disabled()
плюс валидация .minLength() .maxLength() .email() .rule(fn).
Специфика: Select.options({}) , Textarea.rows(), NumberInput.min()/.max(),
FileUpload.multiple().image().accept().maxFiles().maxSizeMB().
Колонки таблицы: TextColumn (.money() .weight('bold') .formatStateUsing()),
BadgeColumn (.colors() .labels()), ImageColumn (.circular()), IconColumn (.icon()).
Общие: .label() .sortable() .searchable() .alignEnd().
Действия: Action.make(name).label().icon().danger().action(fn), готовые EditAction, DeleteAction.
Подключение к реальному бэку (твой NestJS «Свет.ру»)
- В
providers.tsx:auth: jwtAuth(),demo: false,backendUrl= твой адрес (http://localhost:9000/api). - Эндпоинт ресурса (
endpoint: '/products') должен отдавать массив или{ items: [...] }на GET и принимать POST/PATCH/DELETE. - Картинки из
FileUploadприходят какFile[]— для реальной загрузки добавьfromFormValuesв ресурсе (например, заливка в storage и подстановка URL) или отдельный multipart-эндпоинт.
Технически
Чистый Next.js 14 + React 18 + TypeScript, без Tailwind и UI-библиотек. Один globals.css
с дизайн-токенами. Низкоуровневые компоненты — в src/components/admin, библиотека — в src/adminkit.
5. Загрузка картинок (storage-адаптеры)
FileUpload в форме работает с файлами, но на бэк уходит уже string[] (имена/URL),
а при multiple=false — одна string. Куда физически складывать файлы — решает сменный
storage-адаптер, заданный в конфиге (или на конкретном поле).
Адаптеры из коробки
// 1) хранить в самой админке (data URL в localStorage), на бэк — только имена
storage: localStorageAdapter()
// 2) заливать на бэк через multipart, на бэк — то, что вернёт сервер (url/имя)
storage: backendStorageAdapter({ endpoint: '/uploads', field: 'file', responseKey: 'url' })Переопределить для одного поля:
FileUpload.make('images').multiple().image()
.storage(backendStorageAdapter({ endpoint: '/uploads' }))Что уходит на бэк
FileUpload.make('images').multiple()→images: ["1700000000-a.jpg", "1700000001-b.jpg"]FileUpload.make('cover')(без multiple) →cover: "1700000000-a.jpg"(илиnull)
Логика: новые файлы заливаются адаптером и заменяются на строки; при редактировании уже
сохранённые строки переиспользуются (через hydrateFileFields/resolveFileUploads — это
происходит автоматически в ResourcePage).
Пример эндпоинта загрузки на NestJS (для backendStorageAdapter)
// uploads.controller.ts
@Controller('uploads')
export class UploadsController {
@Post()
@UseInterceptors(FileInterceptor('file', {
storage: diskStorage({
destination: './uploads',
filename: (_req, file, cb) =>
cb(null, `${Date.now()}-${file.originalname}`),
}),
}))
upload(@UploadedFile() file: Express.Multer.File) {
// вернёшь url — он и уйдёт в массиве images товара
return { url: `/uploads/${file.filename}`, name: file.filename };
}
}Не забудь раздавать папку статикой (ServeStaticModule или app.useStaticAssets('./uploads')).
6. Select из другой таблицы (relationship)
Варианты селекта можно подтянуть из другой таблицы по API — как relationship в Filament:
// value = id записи, подпись = поле name; грузится с GET /categories
Select.make('categoryId').label('Категория').required()
.optionsFrom('/categories', { value: 'id', label: 'name' })
// или произвольный загрузчик (любой источник)
Select.make('categoryId')
.loadOptions(async (api) => {
const cats = await api.get('/categories');
return cats.map((c) => ({ value: c.id, label: c.name }));
})SchemaForm сам сходит за вариантами при открытии формы (пока грузится — в селекте «Загрузка…»),
поддерживает ответ как массивом, так и { items: [...] }. На бэк уходит выбранный value
(в примере — categoryId).
В demo-режиме (
demo: true) запрос к/categoriesуйдёт вbackendUrlи без бэка вернёт пустой список — поэтому в товарах используется реальныйoptionsFrom('/categories'), а на странице/componentsпоказанloadOptionsсо статикой (работает офлайн).
7. Поиск в селекте и зависимые селекты
Поиск внутри селекта (комбобокс) — когда вариантов много:
Select.make('categoryId').searchable().optionsFrom('/categories', { value: 'id', label: 'name' })Зависимые селекты — список дочернего зависит от выбранного родителя; при смене родителя дочерний перезагружается, а его выбор сбрасывается:
Select.make('categoryId').searchable().optionsFrom('/categories', { value: 'id', label: 'name' }),
Select.make('subcategoryId').searchable().dependsOn('categoryId')
.optionsFrom('/subcategories', { value: 'id', label: 'name' }),
// -> GET /subcategories?categoryId=<выбранное значение>Имя query-параметра по умолчанию = имя поля-родителя; меняется через dependsOn('categoryId', { param: 'parentId' }).
Пока родитель не выбран, дочерний селект заблокирован с подсказкой «Сначала выберите выше».
Для произвольной логики — loadOptions(async (api, values) => …), где values это текущие
значения формы (см. пример категория → подкатегория на странице /components, работает офлайн).
8. Repeater — повторяющиеся группы полей (характеристики товара)
Подходит для характеристик/спецификаций: каждая строка — объект с подполями, на бэк уходит массив объектов.
Repeater.make('attributes').label('Характеристики').columns(2)
.reorderable().addActionLabel('Добавить характеристику').itemLabel('name')
.schema([
TextInput.make('name').label('Параметр').required().placeholder('Мощность'),
TextInput.make('value').label('Значение').required().placeholder('60 Вт'),
])
// -> attributes: [{ name: 'Мощность', value: '60 Вт' }, { name: 'Цоколь', value: 'E27' }]Методы: .schema([...]), .columns(1|2), .defaultItems(n), .minItems(n), .maxItems(n),
.reorderable(), .addActionLabel(s), .itemLabel(key) (что показывать в заголовке строки).
Внутри строки можно использовать любые поля (TextInput, Select, NumberInput, Toggle…).
Подходит ли для характеристик: да — гибче простого «ключ-значение», т.к. в строку легко добавить единицу измерения, группу, тип. Если же нужны строго пары «ключ → значение» без доп. полей, проще плоская структура, но Repeater универсальнее и масштабируется.
9. Автозаполнение slug
Поле slug может автоматически генерироваться из другого поля (транслит кириллицы, нижний регистр, дефисы):
TextInput.make('name').label('Название').required(),
TextInput.make('slug').label('Slug').required().slugFrom('name'),
// «Каскадная люстра Divinare» -> «kaskadnaya-lyustra-divinare»Поведение: slug подстраивается под изменения исходного поля, пока его не отредактируют
вручную — после ручной правки автозаполнение для этой записи отключается. При открытии формы
редактирования существующий slug не затирается. Утилита доступна отдельно: import { slugify } from '@/adminkit'.
10. Темы, разделы, тумблеры оболочки и главная (дашборд)
Редизайн под светлый дашборд (как Zendenta) со скруглёнными панелями и анимациями
(плавные появления, hover-лифт карточек, переключение тем). Низкоуровневые компоненты
переехали из src/components/admin в src/adminkit/ui — всё в одном пакете.
Тема и цвета
theme: { mode: 'light', accent: '#3b5bfd', accent2: '#6d5efc' } // в конфиге провайдераСветлая/тёмная переключаются кнопкой в сайдбаре/топбаре; выбор хранится в localStorage.
Программно: const { mode, toggle, setMode } = useTheme();
Разделы в сайдбаре
navigation: [
{ items: [{ href: '/', label: 'Дашборд', icon: 'home' }] },
{ title: 'Каталог', items: [{ href: '/products', label: 'Товары', icon: 'box' }] },
{ title: 'Финансы', items: [{ href: '/orders', label: 'Заказы', icon: 'wallet', badge: 3 }] },
]Можно и плоским массивом — тогда без заголовков.
Какие элементы оболочки показывать
Глобально в конфиге layout, либо на конкретной странице через проп show у AdminLayout:
<AdminLayout title="Аналитика" show={{ search: false, notifications: false }}>…</AdminLayout>Доступно: search, user, themeToggle, notifications, promo, workspace.
Главная с виджетами (можно менять/добавлять)
Главная — это массив виджетов, который ты сам формируешь:
const widgets: DashboardWidget[] = [
{ id: 'asset', span: 1, flush: true, node: <StatCard label="Склад" value="$9.2M" icon="wallet" /> },
{ id: 'stock', span: 2, title: 'Остатки', node: <ProgressBreakdown segments={[…]} /> },
{ id: 'activity', span: 2, title: 'Последние действия', node: <RecentActivity items={[…]} /> },
];
<Dashboard widgets={widgets} />Готовые виджеты: StatCard, ProgressBreakdown, RecentActivity. Любой свой компонент тоже
можно положить в node.
Под твою будущую БД действий: RecentActivity умеет грузить данные сам —
<RecentActivity load={(api) => api.get('/activity')} />когда сделаешь таблицу логов и эндпоинт /activity (возвращающий [{ title, meta, time }]),
просто подставишь load вместо items — и лента на главной заполнится из твоей базы.
11. Просмотр записи + статусный воркфлоу (как у заказов)
Для сценария «открыть заказ → собрать → отгрузить → доставить» у ресурса есть режим просмотра (infolist) и переключение статусов. Добавляешь в конфиг ресурса:
createResource<Order>({
// ...columns
statusField: 'status',
statuses: [ // линейный воркфлоу
{ value: 'new', label: 'Новый' },
{ value: 'assembling', label: 'Сборка' },
{ value: 'shipped', label: 'Отгружен' },
{ value: 'delivered', label: 'Доставлен' },
],
infolist: () => [ // что показать в окне просмотра
TextEntry.make('number').label('Номер'),
BadgeEntry.make('status').label('Статус').colors({...}).labels({...}),
TextEntry.make('customer').label('Покупатель'),
TextEntry.make('address').label('Адрес').full(),
MoneyEntry.make('total').label('Сумма').currency('RUB'),
DateEntry.make('createdAt').label('Создан'),
TextEntry.make('items').label('Состав').full()
.formatStateUsing((items) => items.map(i => `${i.name} ×${i.qty}`).join(', ')),
],
})В таблице появляется действие «Просмотр» (иконка глаза) → открывается окно с инфолистом и
степпером статусов. Клик по шагу или кнопка «Перевести в …» меняет статус: в demo —
локально, иначе PATCH endpoint/:id { status: '<next>' }. Форму ресурс может не иметь вовсе
(form необязателен) — тогда это «только просмотр + статусы + удаление».
Готовые билдеры просмотра: TextEntry, BadgeEntry, MoneyEntry, DateEntry
(плюс .full() на всю ширину и .formatStateUsing() для своего рендера).
Компоненты доступны и отдельно: <RecordView entries={…} row={…} />, <StatusFlow statuses={…} value onChange />.
Демо: страница /orders — открой заказ и переведи его в «Отгружен».
12. Провайдеры загрузки: Vercel Blob, S3 и свои
StorageAdapter — это просто { upload(file, api) => Promise<string>, resolveUrl? }.
Адаптер возвращает строку (URL/имя), и она уходит на бэк (images: string[] / cover: string).
В комплекте 4 адаптера, легко добавить свой.
Vercel Blob (прямая загрузка в облако, минуя бэк)
// providers.tsx
import { upload } from '@vercel/blob/client'; // пакет ставишь в СВОЁМ приложении
storage: vercelBlobStorageAdapter({ upload, handleUploadUrl: '/upload' })Кит сам @vercel/blob не импортирует (нет лишней зависимости) — клиентскую upload
передаёшь ты. Нужно: npm i @vercel/blob и API-роут app/upload/route.ts:
import { handleUpload, type HandleUploadBody } from '@vercel/blob/client';
import { NextResponse } from 'next/server';
export async function POST(request: Request): Promise<NextResponse> {
const body = (await request.json()) as HandleUploadBody;
try {
const json = await handleUpload({
body, request,
onBeforeGenerateToken: async () => ({
allowedContentTypes: ['image/jpeg', 'image/png', 'image/webp'],
}),
onUploadCompleted: async () => {},
});
return NextResponse.json(json);
} catch (e) {
return NextResponse.json({ error: (e as Error).message }, { status: 400 });
}
}Адаптер сам вызывает клиентский upload(name, file, { access:'public', handleUploadUrl })
и кладёт blob.url в payload — твоему NestJS останется сохранить строку в Prisma.
S3 и прочие (presigned URL)
storage: presignedStorageAdapter({ presignPath: '/uploads/presign' })Бэк отдаёт { uploadUrl, publicUrl } на GET /uploads/presign?name=&type=,
адаптер делает PUT файла на uploadUrl и возвращает publicUrl.
Свой адаптер (например, Cloudinary)
import type { StorageAdapter } from '@/adminkit';
export function myStorageAdapter(): StorageAdapter {
return {
async upload(file, api) {
const url = /* загрузить куда угодно и получить ссылку */;
return url; // строка уходит на бэк
},
resolveUrl: (v) => v, // как показать превью по сохранённой строке
};
}Подключаешь так же — storage: myStorageAdapter(). Можно задать и на конкретном поле:
FileUpload.make('images').multiple().storage(vercelBlobStorageAdapter()).