From 96c7a19ddceedea0e0350c6b706a08a71d06e210 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sat, 10 Jan 2026 02:43:06 +0300 Subject: [PATCH] q --- admin/src/components/ThemeEditorDialog.tsx | 12 +- admin/src/components/ThemeImportDialog.tsx | 334 +++++++++++++++++++++ admin/src/pages/ThemesPage.tsx | 46 ++- backend/Dockerfile | 1 + backend/prisma/seed.ts | 37 ++- src/components/Question.css | 6 + src/context/ThemeContext.jsx | 3 +- 7 files changed, 431 insertions(+), 8 deletions(-) create mode 100644 admin/src/components/ThemeImportDialog.tsx diff --git a/admin/src/components/ThemeEditorDialog.tsx b/admin/src/components/ThemeEditorDialog.tsx index 755171b..4820af9 100644 --- a/admin/src/components/ThemeEditorDialog.tsx +++ b/admin/src/components/ThemeEditorDialog.tsx @@ -5,6 +5,7 @@ import { type ThemeColors, type ThemeSettings, type ThemePreview, + type CreateThemeDto, } from '@/api/themes' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -23,6 +24,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' interface ThemeEditorDialogProps { open: boolean theme: ThemePreview | null + initialData?: Partial onSave: (data: { name: string icon?: string @@ -160,6 +162,7 @@ function ColorField({ label, value, onChange, description }: ColorFieldProps) { export function ThemeEditorDialog({ open, theme, + initialData, onSave, onClose, isSaving = false, @@ -179,6 +182,13 @@ export function ThemeEditorDialog({ setIsPublic(theme.isPublic) setColors(theme.colors) setSettings(theme.settings) + } else if (initialData) { + setName(initialData.name || '') + setIcon(initialData.icon || '') + setDescription(initialData.description || '') + setIsPublic(initialData.isPublic !== undefined ? initialData.isPublic : true) + setColors(initialData.colors || DEFAULT_THEME_COLORS) + setSettings(initialData.settings || DEFAULT_THEME_SETTINGS) } else { setName('') setIcon('') @@ -187,7 +197,7 @@ export function ThemeEditorDialog({ setColors(DEFAULT_THEME_COLORS) setSettings(DEFAULT_THEME_SETTINGS) } - }, [theme, open]) + }, [theme, initialData, open]) const handleSubmit = (e: React.FormEvent) => { e.preventDefault() diff --git a/admin/src/components/ThemeImportDialog.tsx b/admin/src/components/ThemeImportDialog.tsx new file mode 100644 index 0000000..52f30f0 --- /dev/null +++ b/admin/src/components/ThemeImportDialog.tsx @@ -0,0 +1,334 @@ +import { useState, useRef } from 'react' +import { toast } from 'sonner' +import { + type ThemeColors, + type ThemeSettings, + type CreateThemeDto, +} from '@/api/themes' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Upload, FileJson, CheckCircle2, AlertCircle } from 'lucide-react' + +interface ThemeImportDialogProps { + open: boolean + onImport: (theme: CreateThemeDto) => void + onClose: () => void +} + +export function ThemeImportDialog({ + open, + onImport, + onClose, +}: ThemeImportDialogProps) { + const fileInputRef = useRef(null) + const [jsonContent, setJsonContent] = useState('') + const [parseError, setParseError] = useState(null) + const [parsedTheme, setParsedTheme] = useState(null) + + const resetForm = () => { + setJsonContent('') + setParseError(null) + setParsedTheme(null) + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + } + + const handleClose = () => { + resetForm() + onClose() + } + + const validateThemeColors = (colors: unknown): colors is ThemeColors => { + if (!colors || typeof colors !== 'object') { + return false + } + + const requiredColorFields: (keyof ThemeColors)[] = [ + 'bgPrimary', + 'bgOverlay', + 'bgCard', + 'bgCardHover', + 'textPrimary', + 'textSecondary', + 'textGlow', + 'accentPrimary', + 'accentSecondary', + 'accentSuccess', + 'borderColor', + 'borderGlow', + ] + + const colorsObj = colors as Record + return requiredColorFields.every( + (field) => + typeof colorsObj[field] === 'string' && + colorsObj[field].trim().length > 0 + ) + } + + const validateThemeSettings = ( + settings: unknown + ): settings is ThemeSettings => { + if (!settings || typeof settings !== 'object') { + return false + } + + const requiredSettingFields: (keyof ThemeSettings)[] = [ + 'shadowSm', + 'shadowMd', + 'shadowLg', + 'blurAmount', + 'borderRadiusSm', + 'borderRadiusMd', + 'borderRadiusLg', + 'animationSpeed', + ] + + const settingsObj = settings as Record + return requiredSettingFields.every( + (field) => + typeof settingsObj[field] === 'string' && + settingsObj[field].trim().length > 0 + ) + } + + const parseJsonContent = (content: string): CreateThemeDto | null => { + try { + const parsed = JSON.parse(content) + + if (!parsed.name || typeof parsed.name !== 'string' || !parsed.name.trim()) { + throw new Error('Theme name is required and must be a non-empty string') + } + + if (!validateThemeColors(parsed.colors)) { + throw new Error( + 'Invalid theme colors. All color fields are required: bgPrimary, bgOverlay, bgCard, bgCardHover, textPrimary, textSecondary, textGlow, accentPrimary, accentSecondary, accentSuccess, borderColor, borderGlow' + ) + } + + if (!validateThemeSettings(parsed.settings)) { + throw new Error( + 'Invalid theme settings. All setting fields are required: shadowSm, shadowMd, shadowLg, blurAmount, borderRadiusSm, borderRadiusMd, borderRadiusLg, animationSpeed' + ) + } + + const theme: CreateThemeDto = { + name: parsed.name.trim(), + icon: parsed.icon && typeof parsed.icon === 'string' ? parsed.icon.trim() : undefined, + description: + parsed.description && typeof parsed.description === 'string' + ? parsed.description.trim() + : undefined, + isPublic: parsed.isPublic !== undefined ? Boolean(parsed.isPublic) : true, + colors: parsed.colors, + settings: parsed.settings, + } + + setParseError(null) + setParsedTheme(theme) + return theme + } catch (e) { + const errorMessage = + e instanceof Error ? e.message : 'Invalid JSON format' + setParseError(errorMessage) + setParsedTheme(null) + return null + } + } + + const handleFileSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + + const reader = new FileReader() + reader.onload = (event) => { + const content = event.target?.result as string + setJsonContent(content) + parseJsonContent(content) + } + reader.onerror = () => { + toast.error('Failed to read file') + setParseError('Failed to read file') + } + reader.readAsText(file) + } + + const handleJsonChange = (value: string) => { + setJsonContent(value) + if (value.trim()) { + parseJsonContent(value) + } else { + setParseError(null) + setParsedTheme(null) + } + } + + const handleImport = () => { + if (!jsonContent.trim()) { + toast.error('JSON content is required') + return + } + + const theme = parseJsonContent(jsonContent) + if (!theme) { + toast.error('Invalid theme JSON format') + return + } + + onImport(theme) + handleClose() + } + + return ( + !isOpen && handleClose()}> + + + Import Theme from JSON + + Upload a JSON file or paste JSON content to import a theme. The JSON + should contain theme name, colors, and settings. + + + +
+ {/* File Upload */} +
+ +
+ + + {fileInputRef.current?.files?.[0] && ( + + + {fileInputRef.current.files[0].name} + + )} +
+
+ + {/* JSON Content */} +
+ +