This commit is contained in:
Dmitry 2026-01-10 02:43:06 +03:00
parent ea1ac0114f
commit 96c7a19ddc
7 changed files with 431 additions and 8 deletions

View file

@ -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<CreateThemeDto>
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()

View file

@ -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<HTMLInputElement>(null)
const [jsonContent, setJsonContent] = useState('')
const [parseError, setParseError] = useState<string | null>(null)
const [parsedTheme, setParsedTheme] = useState<CreateThemeDto | null>(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<string, unknown>
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<string, unknown>
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<HTMLInputElement>) => {
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 (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Import Theme from JSON</DialogTitle>
<DialogDescription>
Upload a JSON file or paste JSON content to import a theme. The JSON
should contain theme name, colors, and settings.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{/* File Upload */}
<div className="space-y-2">
<Label>Upload JSON File</Label>
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileSelect}
className="hidden"
/>
<Button
type="button"
variant="outline"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-4 w-4 mr-2" />
Select File
</Button>
{fileInputRef.current?.files?.[0] && (
<span className="text-sm text-muted-foreground flex items-center gap-1">
<FileJson className="h-4 w-4" />
{fileInputRef.current.files[0].name}
</span>
)}
</div>
</div>
{/* JSON Content */}
<div className="space-y-2">
<Label htmlFor="jsonContent">Or Paste JSON Content</Label>
<Textarea
id="jsonContent"
value={jsonContent}
onChange={(e) => handleJsonChange(e.target.value)}
placeholder={`{
"name": "My Theme",
"icon": "🎨",
"description": "Theme description",
"isPublic": true,
"colors": {
"bgPrimary": "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
"bgOverlay": "rgba(0, 0, 0, 0.7)",
"bgCard": "rgba(255, 255, 255, 0.1)",
"bgCardHover": "rgba(255, 255, 255, 0.2)",
"textPrimary": "#ffffff",
"textSecondary": "rgba(255, 255, 255, 0.8)",
"textGlow": "#ffd700",
"accentPrimary": "#ffd700",
"accentSecondary": "#ffed4e",
"accentSuccess": "#4ade80",
"borderColor": "rgba(255, 255, 255, 0.2)",
"borderGlow": "rgba(255, 215, 0, 0.3)"
},
"settings": {
"shadowSm": "0 1px 2px rgba(0, 0, 0, 0.1)",
"shadowMd": "0 4px 6px rgba(0, 0, 0, 0.1)",
"shadowLg": "0 10px 15px rgba(0, 0, 0, 0.2)",
"blurAmount": "10px",
"borderRadiusSm": "4px",
"borderRadiusMd": "8px",
"borderRadiusLg": "12px",
"animationSpeed": "0.3s"
}
}`}
rows={15}
className="font-mono text-sm"
/>
{parseError && (
<div className="flex items-start gap-2 text-sm text-red-500">
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
<span>{parseError}</span>
</div>
)}
{parsedTheme && !parseError && (
<div className="flex items-start gap-2 text-sm text-green-600">
<CheckCircle2 className="h-4 w-4 mt-0.5 flex-shrink-0" />
<div>
<div className="font-medium">Theme validated successfully!</div>
<div className="text-xs text-muted-foreground mt-1">
Name: {parsedTheme.name}
{parsedTheme.icon && ` • Icon: ${parsedTheme.icon}`}
{parsedTheme.description && `${parsedTheme.description.substring(0, 50)}${parsedTheme.description.length > 50 ? '...' : ''}`}
</div>
</div>
</div>
)}
</div>
{/* JSON Format Example */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
Expected JSON Structure:
</Label>
<div className="text-xs text-muted-foreground bg-muted p-3 rounded border font-mono">
<div>{'{'}</div>
<div className="pl-4">"name": "string (required)",</div>
<div className="pl-4">"icon": "string (optional)",</div>
<div className="pl-4">"description": "string (optional)",</div>
<div className="pl-4">"isPublic": boolean (optional, default: true),</div>
<div className="pl-4">"colors": {'{'}</div>
<div className="pl-8">bgPrimary, bgOverlay, bgCard, bgCardHover,</div>
<div className="pl-8">textPrimary, textSecondary, textGlow,</div>
<div className="pl-8">accentPrimary, accentSecondary, accentSuccess,</div>
<div className="pl-8">borderColor, borderGlow</div>
<div className="pl-4">{'}'},</div>
<div className="pl-4">"settings": {'{'}</div>
<div className="pl-8">shadowSm, shadowMd, shadowLg, blurAmount,</div>
<div className="pl-8">borderRadiusSm, borderRadiusMd, borderRadiusLg, animationSpeed</div>
<div className="pl-4">{'}'}</div>
<div>{'}'}</div>
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button
onClick={handleImport}
disabled={!!parseError || !parsedTheme}
>
Import Theme
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -33,8 +33,10 @@ import {
} from '@/components/ui/alert-dialog'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react'
import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight, Upload } from 'lucide-react'
import { ThemeEditorDialog } from '@/components/ThemeEditorDialog'
import { ThemeImportDialog } from '@/components/ThemeImportDialog'
import type { CreateThemeDto } from '@/api/themes'
export default function ThemesPage() {
const queryClient = useQueryClient()
@ -43,6 +45,8 @@ export default function ThemesPage() {
const [showPrivate, setShowPrivate] = useState(true)
const [isEditorOpen, setIsEditorOpen] = useState(false)
const [editingTheme, setEditingTheme] = useState<ThemePreview | null>(null)
const [importedData, setImportedData] = useState<CreateThemeDto | null>(null)
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [themeToDelete, setThemeToDelete] = useState<ThemePreview | null>(null)
@ -109,17 +113,35 @@ export default function ThemesPage() {
const openCreateEditor = () => {
setEditingTheme(null)
setImportedData(null)
setIsEditorOpen(true)
}
const openEditEditor = (theme: ThemePreview) => {
setEditingTheme(theme)
setImportedData(null)
setIsEditorOpen(true)
}
const closeEditor = () => {
setIsEditorOpen(false)
setEditingTheme(null)
setImportedData(null)
}
const openImportDialog = () => {
setIsImportDialogOpen(true)
}
const closeImportDialog = () => {
setIsImportDialogOpen(false)
}
const handleThemeImport = (theme: CreateThemeDto) => {
setImportedData(theme)
setEditingTheme(null)
setIsImportDialogOpen(false)
setIsEditorOpen(true)
}
const handleSave = (formData: {
@ -191,11 +213,17 @@ export default function ThemesPage() {
<h1 className="text-3xl font-bold tracking-tight">Themes Management</h1>
<p className="text-muted-foreground">Create and manage game themes</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={openImportDialog}>
<Upload className="mr-2 h-4 w-4" />
Import from JSON
</Button>
<Button onClick={openCreateEditor}>
<Plus className="mr-2 h-4 w-4" />
Create Theme
</Button>
</div>
</div>
{/* Search and Filters */}
<Card>
@ -345,10 +373,18 @@ export default function ThemesPage() {
</CardContent>
</Card>
{/* Theme Import Dialog */}
<ThemeImportDialog
open={isImportDialogOpen}
onImport={handleThemeImport}
onClose={closeImportDialog}
/>
{/* Theme Editor Dialog */}
<ThemeEditorDialog
open={isEditorOpen}
theme={editingTheme}
initialData={importedData || undefined}
onSave={handleSave}
onClose={closeEditor}
isSaving={createMutation.isPending || updateMutation.isPending}

View file

@ -15,6 +15,7 @@ RUN npm install -g npm@11.7.0
COPY package.json ./
COPY prisma ./prisma/
COPY prisma.config.ts ./
COPY tsconfig.json ./
RUN npm install --production && \
npm install --save-dev ts-node typescript @types/node && \
npm cache clean --force

View file

@ -1,10 +1,45 @@
import { PrismaClient } from '@prisma/client';
import * as fs from 'fs';
import * as path from 'path';
import { ensureQuestionIds } from '../src/utils/question-utils';
import { randomUUID } from 'crypto';
const prisma = new PrismaClient();
// Helper function: Add UUIDs to questions and answers if they don't have them
// This is a copy from src/utils/question-utils.ts to avoid dependency on src in production
interface Answer {
id?: string;
text: string;
points: number;
}
interface Question {
id?: string;
text?: string;
question?: string;
answers: Answer[];
}
function ensureQuestionIds(questions: Question[]): Question[] {
return questions.map((question) => {
const questionId = question.id || randomUUID();
const questionText = question.text || question.question || '';
const answersWithIds = question.answers.map((answer) => ({
...answer,
id: answer.id || randomUUID(),
}));
return {
...question,
id: questionId,
text: questionText,
question: questionText, // Keep both fields for compatibility
answers: answersWithIds,
};
});
}
async function main() {
console.log('Starting seed...');

View file

@ -133,6 +133,12 @@
min-height: 0;
}
@media (min-width: 1200px) {
.answers-grid {
gap: 12px;
}
}
@media (max-width: 768px) {
.answers-grid {
grid-template-columns: 1fr;

View file

@ -25,7 +25,8 @@ export const ThemeProvider = ({ children }) => {
useEffect(() => {
const loadThemes = async () => {
try {
const response = await fetch('/api/themes');
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
const response = await fetch(`${API_URL}/api/themes`);
if (response.ok) {
const data = await response.json();
setThemes(data);