feat: skin engine + Twenty CRM theme for iistwin

- Add client/src/themes/ with tokens, built-in skins (iistwin, twenty, omegaprom)
- Add ThemeProvider with data-skin/data-theme and runtime CSS for custom skins
- Add npm run skins:generate script for build-time CSS generation
- Add organization_skins table, migration and CRUD API
- Add activeSkinId to organizations and expose it in /api/auth/me
- Add skin switcher in /profile and admin skin editor in /settings/appearance
- Adapt index.css hardcoded colors to skin tokens
- Update AGENTS.md and Obsidian notes
This commit is contained in:
2026-07-09 20:25:49 +03:00
parent 7ed89f7f97
commit 95279b8fcc
24 changed files with 2562 additions and 223 deletions

View File

@@ -0,0 +1,294 @@
import { Router } from 'express';
import { eq, and } from 'drizzle-orm';
import { db } from '../db';
import { organizationSkins, organizations } from '@shared/schema';
import { authenticateToken, requirePermission, type AuthenticatedRequest } from '../middleware/auth.middleware';
import { tenantIsolation } from '../middleware/tenant.middleware';
import { BUILT_IN_SKINS, getBuiltInSkinById } from '../../client/src/themes';
import type { Skin } from '../../client/src/themes/tokens';
const router = Router();
interface SkinListItem {
id: string;
name: string;
description?: string;
isEditable: boolean;
isBuiltIn: boolean;
dbId?: number;
}
function serializeSkinListItem(skin: Skin, isBuiltIn: boolean, dbId?: number): SkinListItem {
return {
id: skin.id,
name: skin.name,
description: skin.description,
isEditable: isBuiltIn ? false : true,
isBuiltIn,
dbId,
};
}
// GET /api/organization/skins — список скинов (встроенные + пользовательские)
router.get('/api/organization/skins', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
try {
const orgId = req.organizationId!;
const customRows = await db
.select()
.from(organizationSkins)
.where(eq(organizationSkins.organizationId, orgId));
const builtInList = BUILT_IN_SKINS.map((skin) => serializeSkinListItem(skin, true));
const customList = customRows.map((row) =>
serializeSkinListItem({
id: row.skinId,
name: row.name,
description: row.description ?? undefined,
tokens: row.tokens as Skin['tokens'],
} as Skin, false, row.id)
);
return res.json({ success: true, skins: [...builtInList, ...customList] });
} catch (error) {
console.error('Get organization skins error:', error);
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
// GET /api/organization/skins/:id — детали скина
router.get('/api/organization/skins/:id', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
try {
const orgId = req.organizationId!;
const skinId = req.params.id;
const builtIn = getBuiltInSkinById(skinId);
if (builtIn) {
return res.json({ success: true, skin: builtIn });
}
const [row] = await db
.select()
.from(organizationSkins)
.where(and(eq(organizationSkins.organizationId, orgId), eq(organizationSkins.skinId, skinId)));
if (!row) {
return res.status(404).json({ success: false, error: 'Скин не найден' });
}
const skin: Skin = {
id: row.skinId,
name: row.name,
description: row.description ?? undefined,
isEditable: row.isEditable ?? true,
tokens: row.tokens as Skin['tokens'],
};
return res.json({ success: true, skin });
} catch (error) {
console.error('Get organization skin error:', error);
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
// POST /api/organization/skins — создать пользовательский скин
router.post('/api/organization/skins', authenticateToken, requirePermission('settings.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => {
try {
const orgId = req.organizationId!;
const { skinId, name, description, tokens, baseSkinId } = req.body;
if (!skinId || typeof skinId !== 'string') {
return res.status(400).json({ success: false, error: 'skinId обязателен' });
}
if (!name || typeof name !== 'string') {
return res.status(400).json({ success: false, error: 'Название скина обязательно' });
}
if (getBuiltInSkinById(skinId)) {
return res.status(400).json({ success: false, error: 'Нельзя создать скин с id встроенного скина' });
}
let finalTokens = tokens;
if (!finalTokens && baseSkinId) {
const base = getBuiltInSkinById(baseSkinId) || await db.query.organizationSkins.findFirst({
where: and(eq(organizationSkins.organizationId, orgId), eq(organizationSkins.skinId, baseSkinId)),
});
if (base) {
finalTokens = (base as any).tokens ?? (base as Skin).tokens;
}
}
if (!finalTokens) {
return res.status(400).json({ success: false, error: 'Токены скина обязательны' });
}
const [row] = await db
.insert(organizationSkins)
.values({
organizationId: orgId,
skinId,
name: name.trim(),
description: description ?? null,
tokens: finalTokens,
isEditable: true,
isActive: false,
createdBy: req.user!.id,
})
.returning();
const skin: Skin = {
id: row.skinId,
name: row.name,
description: row.description ?? undefined,
isEditable: true,
tokens: row.tokens as Skin['tokens'],
};
return res.status(201).json({ success: true, skin, dbId: row.id });
} catch (error: any) {
if (error?.code === '23505') {
return res.status(409).json({ success: false, error: 'Скин с таким id уже существует' });
}
console.error('Create organization skin error:', error);
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
// PATCH /api/organization/skins/:id — обновить пользовательский скин
router.patch('/api/organization/skins/:id', authenticateToken, requirePermission('settings.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => {
try {
const orgId = req.organizationId!;
const skinId = req.params.id;
const { name, description, tokens } = req.body;
if (getBuiltInSkinById(skinId)) {
return res.status(400).json({ success: false, error: 'Встроенные скины нельзя редактировать' });
}
const updates: Partial<{ name: string; description: string | null; tokens: object }> = {};
if (name !== undefined) updates.name = name.trim();
if (description !== undefined) updates.description = description ?? null;
if (tokens !== undefined) updates.tokens = tokens;
const [row] = await db
.update(organizationSkins)
.set({ ...updates, updatedAt: new Date() })
.where(and(eq(organizationSkins.organizationId, orgId), eq(organizationSkins.skinId, skinId)))
.returning();
if (!row) {
return res.status(404).json({ success: false, error: 'Скин не найден' });
}
const skin: Skin = {
id: row.skinId,
name: row.name,
description: row.description ?? undefined,
isEditable: row.isEditable ?? true,
tokens: row.tokens as Skin['tokens'],
};
return res.json({ success: true, skin });
} catch (error) {
console.error('Update organization skin error:', error);
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
// DELETE /api/organization/skins/:id — удалить пользовательский скин
router.delete('/api/organization/skins/:id', authenticateToken, requirePermission('settings.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => {
try {
const orgId = req.organizationId!;
const skinId = req.params.id;
if (getBuiltInSkinById(skinId)) {
return res.status(400).json({ success: false, error: 'Встроенные скины нельзя удалять' });
}
const [row] = await db
.delete(organizationSkins)
.where(and(eq(organizationSkins.organizationId, orgId), eq(organizationSkins.skinId, skinId)))
.returning();
if (!row) {
return res.status(404).json({ success: false, error: 'Скин не найден' });
}
// Если удалён активный скин — сбрасываем на iistwin
await db
.update(organizations)
.set({ activeSkinId: 'iistwin', updatedAt: new Date() })
.where(and(eq(organizations.id, orgId), eq(organizations.activeSkinId, skinId)));
return res.json({ success: true });
} catch (error) {
console.error('Delete organization skin error:', error);
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
// GET /api/organization/current-skin — текущий активный скин организации
router.get('/api/organization/current-skin', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
try {
const orgId = req.organizationId!;
const [org] = await db
.select({ activeSkinId: organizations.activeSkinId })
.from(organizations)
.where(eq(organizations.id, orgId));
const activeSkinId = org?.activeSkinId ?? 'iistwin';
const builtIn = getBuiltInSkinById(activeSkinId);
if (builtIn) {
return res.json({ success: true, skinId: activeSkinId, skin: builtIn });
}
const [row] = await db
.select()
.from(organizationSkins)
.where(and(eq(organizationSkins.organizationId, orgId), eq(organizationSkins.skinId, activeSkinId)));
if (!row) {
return res.json({ success: true, skinId: 'iistwin', skin: getBuiltInSkinById('iistwin') });
}
const skin: Skin = {
id: row.skinId,
name: row.name,
description: row.description ?? undefined,
isEditable: row.isEditable ?? true,
tokens: row.tokens as Skin['tokens'],
};
return res.json({ success: true, skinId: activeSkinId, skin });
} catch (error) {
console.error('Get current organization skin error:', error);
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
// PUT /api/organization/current-skin — установить активный скин
router.put('/api/organization/current-skin', authenticateToken, requirePermission('settings.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => {
try {
const orgId = req.organizationId!;
const { skinId } = req.body;
if (!skinId || typeof skinId !== 'string') {
return res.status(400).json({ success: false, error: 'skinId обязателен' });
}
const isBuiltIn = !!getBuiltInSkinById(skinId);
if (!isBuiltIn) {
const [custom] = await db
.select({ id: organizationSkins.id })
.from(organizationSkins)
.where(and(eq(organizationSkins.organizationId, orgId), eq(organizationSkins.skinId, skinId)));
if (!custom) {
return res.status(404).json({ success: false, error: 'Скин не найден' });
}
}
await db
.update(organizations)
.set({ activeSkinId: skinId, updatedAt: new Date() })
.where(eq(organizations.id, orgId));
return res.json({ success: true, skinId });
} catch (error) {
console.error('Set current organization skin error:', error);
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
export default router;