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:
294
server/routes/organization-skins.routes.ts
Normal file
294
server/routes/organization-skins.routes.ts
Normal 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;
|
||||
Reference in New Issue
Block a user