From 95279b8fccb4710190611ffb133d788ff758d0fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Thu, 9 Jul 2026 20:25:49 +0300 Subject: [PATCH] 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 --- client/src/App.tsx | 10 +- .../src/components/folders/FormFolderTree.tsx | 3 +- client/src/hooks/use-theme.tsx | 60 +-- client/src/index.css | 105 ++-- client/src/pages/AppearanceSettings.tsx | 457 +++++++++++++++++ client/src/pages/Forms.tsx | 92 +--- client/src/pages/ProfileSettings.tsx | 25 +- client/src/pages/Settings.tsx | 13 +- client/src/themes/ThemeProvider.tsx | 218 ++++++++ client/src/themes/index.ts | 23 + client/src/themes/skins/iistwin.ts | 206 ++++++++ client/src/themes/skins/omegaprom.ts | 206 ++++++++ client/src/themes/skins/twenty.ts | 208 ++++++++ client/src/themes/styles/generated-skins.css | 469 ++++++++++++++++++ client/src/themes/tokens.ts | 290 +++++++++++ client/src/types/auth.types.ts | 1 + migrations/0054_organization_skins.sql | 27 + package.json | 1 + scripts/dev/generate-skins.ts | 8 + server/routes/auth.core.routes.ts | 3 +- server/routes/index.ts | 2 + server/routes/organization-skins.routes.ts | 294 +++++++++++ shared/schema.ts | 33 ++ tailwind.config.ts | 31 ++ 24 files changed, 2562 insertions(+), 223 deletions(-) create mode 100644 client/src/pages/AppearanceSettings.tsx create mode 100644 client/src/themes/ThemeProvider.tsx create mode 100644 client/src/themes/index.ts create mode 100644 client/src/themes/skins/iistwin.ts create mode 100644 client/src/themes/skins/omegaprom.ts create mode 100644 client/src/themes/skins/twenty.ts create mode 100644 client/src/themes/styles/generated-skins.css create mode 100644 client/src/themes/tokens.ts create mode 100644 migrations/0054_organization_skins.sql create mode 100644 scripts/dev/generate-skins.ts create mode 100644 server/routes/organization-skins.routes.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index c3a82f7..e6d2e8f 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -5,7 +5,7 @@ import { Toaster } from "@/components/ui/toaster"; import { ToastAction } from "@/components/ui/toast"; import { TooltipProvider } from "@/components/ui/tooltip"; import { useAuthProvider, AuthContext } from "@/hooks/useAuth"; -import { ThemeProvider } from "@/hooks/use-theme"; +import { ThemeProvider } from "@/themes/ThemeProvider"; import { OfflineSyncProvider } from "@/contexts/OfflineSyncContext"; import { useEffect, lazy, Suspense, useCallback, type ComponentType, type LazyExoticComponent } from "react"; import { initializePushNotifications } from "@/lib/push-notifications"; @@ -243,8 +243,8 @@ function App() { return ( - - + + @@ -257,8 +257,8 @@ function App() { - - + + ); } diff --git a/client/src/components/folders/FormFolderTree.tsx b/client/src/components/folders/FormFolderTree.tsx index 6f55721..8842054 100644 --- a/client/src/components/folders/FormFolderTree.tsx +++ b/client/src/components/folders/FormFolderTree.tsx @@ -295,8 +295,7 @@ function FolderNodeContent({ isOver && 'ring-2 ring-primary bg-primary/10' )} style={{ paddingLeft: `${item.level * 16 + 8}px` }} - onClick={() => onSelectFolder(item.id)} - onDoubleClick={() => onToggleExpand(item.id)} + onClick={() => onToggleExpand(item.id)} > + + {!skin.isBuiltIn && skin.isEditable && ( + + )} + + + ); + })} + + + + + + {effectiveSkin && !isSelectedBuiltIn && ( + + + Редактор скина «{effectiveSkin.name}» + Изменяйте цвета, радиусы и тени. Сохранённые изменения применятся ко всей организации. + + +
+
+ + setForm((prev) => ({ + name: e.target.value, + description: prev?.description ?? effectiveSkin.description ?? '', + tokens: prev?.tokens ?? { + light: cloneTokens(effectiveSkin.tokens.light), + dark: cloneTokens(effectiveSkin.tokens.dark), + }, + }))} + /> +
+
+ + setForm((prev) => ({ + name: prev?.name ?? effectiveSkin.name, + description: e.target.value, + tokens: prev?.tokens ?? { + light: cloneTokens(effectiveSkin.tokens.light), + dark: cloneTokens(effectiveSkin.tokens.dark), + }, + }))} + /> +
+
+ + setPreviewTheme(v as Theme)}> + + Светлая тема + Тёмная тема + + + + +
+
+
Primary
+
Secondary
+
Danger
+
+
+
Card
+
Muted
+
Accent
+
+
+ +
+

Цвета

+
+ {EDITABLE_COLOR_TOKENS.map((token) => { + const value = getTokenValue(form?.tokens ?? effectiveSkin.tokens, previewTheme, token.key) as string; + return ( +
+ +
+ { + const next = setTokenValue(form?.tokens ?? effectiveSkin.tokens, previewTheme, token.key, e.target.value); + setForm((prev) => ({ + name: prev?.name ?? effectiveSkin.name, + description: prev?.description ?? effectiveSkin.description ?? '', + tokens: next, + })); + }} + className="h-8 w-8 rounded border p-0.5" + /> + { + const next = setTokenValue(form?.tokens ?? effectiveSkin.tokens, previewTheme, token.key, e.target.value); + setForm((prev) => ({ + name: prev?.name ?? effectiveSkin.name, + description: prev?.description ?? effectiveSkin.description ?? '', + tokens: next, + })); + }} + className="h-8 text-xs" + /> +
+
+ ); + })} +
+
+ +
+

Радиусы

+
+ {EDITABLE_RADIUS_TOKENS.map((token) => { + const value = getTokenValue(form?.tokens ?? effectiveSkin.tokens, previewTheme, token.key) as string; + return ( +
+ + { + const next = setTokenValue(form?.tokens ?? effectiveSkin.tokens, previewTheme, token.key, e.target.value); + setForm((prev) => ({ + name: prev?.name ?? effectiveSkin.name, + description: prev?.description ?? effectiveSkin.description ?? '', + tokens: next, + })); + }} + className="h-8 text-xs" + /> +
+ ); + })} +
+
+ +
+ +
+
+
+ )} + + ); +} + +const DEFAULT_SKIN_ID = 'iistwin'; diff --git a/client/src/pages/Forms.tsx b/client/src/pages/Forms.tsx index aab8ea5..3e0737c 100644 --- a/client/src/pages/Forms.tsx +++ b/client/src/pages/Forms.tsx @@ -12,10 +12,9 @@ import { Plus, Search, Folder, X, FileText } from 'lucide-react'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { FolderTreeWithItems } from '@/components/folders/FolderTreeWithItems'; -import { Checkbox } from '@/components/ui/checkbox'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; -import { insertFormSchema, updateFormSchema } from '@shared/schema'; +import { insertFormSchema } from '@shared/schema'; import { z } from 'zod'; import { useToast } from '@/hooks/use-toast'; import { queryClient, apiRequest } from '@/lib/queryClient'; @@ -48,7 +47,6 @@ const clientFormSchema = insertFormSchema.omit({ }); type FormCreateData = z.infer; -type FormUpdateData = z.infer; interface FormData { id: number; @@ -71,8 +69,6 @@ function parseFormDraggableId(id: string | number): number | null { const Forms = () => { const [createModalOpen, setCreateModalOpen] = useState(false); - const [editModalOpen, setEditModalOpen] = useState(false); - const [editingForm, setEditingForm] = useState(null); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [deletingForm, setDeletingForm] = useState(null); const [tasksCount, setTasksCount] = useState<{ total: number; active: number; completed: number }>({ total: 0, active: 0, completed: 0 }); @@ -106,15 +102,6 @@ const Forms = () => { }, }); - const editForm = useForm({ - resolver: zodResolver(updateFormSchema), - defaultValues: { - name: '', - description: '', - isActive: true, - }, - }); - const { data: formsData, isLoading } = useQuery<{ success: boolean; forms: FormData[] }>({ queryKey: ['/api/forms'], queryFn: async () => { @@ -155,22 +142,6 @@ const Forms = () => { }, }); - const updateFormMutation = useMutation({ - mutationFn: async ({ id, data }: { id: number; data: FormUpdateData }) => { - return apiRequest('PUT', `/api/forms/${id}`, data); - }, - onSuccess: () => { - toast({ title: 'Форма обновлена', description: 'Форма успешно обновлена' }); - setEditModalOpen(false); - setEditingForm(null); - editForm.reset(); - queryClient.invalidateQueries({ queryKey: ['/api/forms'] }); - }, - onError: (error: any) => { - toast({ title: 'Ошибка', description: error.message || 'Не удалось обновить форму', variant: 'destructive' }); - }, - }); - const deleteFormMutation = useMutation({ mutationFn: async (formId: number) => { return apiRequest('DELETE', `/api/forms/${formId}`); @@ -187,18 +158,9 @@ const Forms = () => { }); const onSubmit = (data: FormCreateData) => createFormMutation.mutate(data); - const onEditSubmit = (data: FormUpdateData) => { - if (editingForm) updateFormMutation.mutate({ id: editingForm.id, data }); - }; const handleEditForm = (formData: FormData) => { - setEditingForm(formData); - editForm.reset({ - name: formData.name, - description: formData.description || '', - isActive: formData.isActive, - }); - setEditModalOpen(true); + setLocation(`/forms/${formData.id}/edit`); }; const handleDeleteForm = async (formData: FormData) => { @@ -516,7 +478,7 @@ const Forms = () => { - {/* Create/Edit Form Dialogs */} + {/* Create Form Dialog */} @@ -554,54 +516,6 @@ const Forms = () => { - - - - Редактировать форму - Изменение настроек формы iistwin - -
- - ( - - Название формы * - - - - - - )} /> - ( - - Описание - -