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

@@ -32,12 +32,33 @@ export const organizations = pgTable("organizations", {
displayName: varchar("display_name", { length: 200 }).notNull(),
logoUrl: varchar("logo_url", { length: 500 }),
themeColor: varchar("theme_color", { length: 7 }).default("#1976d2"),
activeSkinId: varchar("active_skin_id", { length: 50 }).default("iistwin"),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
isActive: boolean("is_active").default(true),
billingBlocked: boolean("billing_blocked").default(false),
});
// Organization skins
export const organizationSkins = pgTable("organization_skins", {
id: serial("id").primaryKey(),
organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
skinId: varchar("skin_id", { length: 50 }).notNull(),
name: varchar("name", { length: 100 }).notNull(),
description: text("description"),
tokens: jsonb("tokens").notNull(),
isEditable: boolean("is_editable").default(true),
isActive: boolean("is_active").default(false),
createdBy: integer("created_by").notNull().references(() => users.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
}, (table) => ({
uniqueOrgSkin: unique().on(table.organizationId, table.skinId),
}));
export type OrganizationSkin = typeof organizationSkins.$inferSelect;
export type InsertOrganizationSkin = typeof organizationSkins.$inferInsert;
// Billing settings per organization (1:1)
export const organizationBilling = pgTable("organization_billing", {
organizationId: integer("organization_id").primaryKey().references(() => organizations.id, { onDelete: "cascade" }),
@@ -678,6 +699,18 @@ export const messageReads = pgTable("message_reads", {
export const organizationsRelations = relations(organizations, ({ many }) => ({
users: many(users),
forms: many(forms),
skins: many(organizationSkins),
}));
export const organizationSkinsRelations = relations(organizationSkins, ({ one }) => ({
organization: one(organizations, {
fields: [organizationSkins.organizationId],
references: [organizations.id],
}),
createdByUser: one(users, {
fields: [organizationSkins.createdBy],
references: [users.id],
}),
}));
export const usersRelations = relations(users, ({ one, many }) => ({