diff --git a/backend/api/management/commands/seed_banners.py b/backend/api/management/commands/seed_banners.py index 2fc45d2..5c6cf15 100644 --- a/backend/api/management/commands/seed_banners.py +++ b/backend/api/management/commands/seed_banners.py @@ -37,18 +37,26 @@ class Command(BaseCommand): if not created: # Sync missing settings from JSON to DB without overwriting existing keys - db_settings = banner.settings or {} - json_settings = data.get('settings', {}) + db_settings = banner.settings or {} + json_settings = data.get('settings', {}) + json_schema = data.get('schema', []) + + updated = False + # Sync missing settings + for key, value in json_settings.items(): + if key not in db_settings: + db_settings[key] = value + updated = True + + # Always sync schema as it represents the UI structure + if banner.schema != json_schema: + banner.schema = json_schema + updated = True + + if updated: + banner.settings = db_settings + banner.save() - updated = False - for key, value in json_settings.items(): - if key not in db_settings: - db_settings[key] = value - updated = True - - if updated: - banner.settings = db_settings - banner.save() self.stdout.write(self.style.SUCCESS(f"Synced new settings for banner '{slug}' from {filename}")) else: self.stdout.write(self.style.SUCCESS(f"Created new banner '{slug}' from {filename}")) diff --git a/backend/api/migrations/0025_banner_schema.py b/backend/api/migrations/0025_banner_schema.py new file mode 100644 index 0000000..738db2d --- /dev/null +++ b/backend/api/migrations/0025_banner_schema.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.2 on 2026-02-18 13:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0024_seed_banners'), + ] + + operations = [ + migrations.AddField( + model_name='banner', + name='schema', + field=models.JSONField(blank=True, default=list), + ), + ] diff --git a/backend/api/models.py b/backend/api/models.py index d12353e..b3c43fa 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -197,6 +197,7 @@ class Banner(models.Model): description = models.TextField(blank=True) is_active = models.BooleanField(default=False) settings = models.JSONField(default=dict, blank=True) + schema = models.JSONField(default=list, blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/backend/api/serializers.py b/backend/api/serializers.py index 0c91029..32f89eb 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -9,7 +9,7 @@ class CategorySerializer(serializers.ModelSerializer): class BannerSerializer(serializers.ModelSerializer): class Meta: model = Banner - fields = ['id', 'name', 'slug', 'description', 'is_active', 'settings', 'created_at', 'updated_at'] + fields = ['id', 'name', 'slug', 'description', 'is_active', 'settings', 'schema', 'created_at', 'updated_at'] class ReplySerializer(serializers.ModelSerializer): user = serializers.StringRelatedField(read_only=True) diff --git a/backend/banners/generative-web.json b/backend/banners/generative-web.json index 306303e..369f4de 100644 --- a/backend/banners/generative-web.json +++ b/backend/banners/generative-web.json @@ -7,5 +7,42 @@ "connectionDist": 140, "speed": 0.005, "opacity": 0.35 - } + }, + "schema": [ + { + "key": "nodeCountBase", + "label": "Base Node Count", + "type": "number", + "min": 10, + "max": 500, + "description": "The starting number of nodes in the mesh." + }, + { + "key": "connectionDist", + "label": "Connection Distance", + "type": "slider", + "min": 50, + "max": 500, + "step": 10, + "description": "The maximum distance at which nodes will connect to form the web." + }, + { + "key": "speed", + "label": "Animation Speed", + "type": "slider", + "min": 0.001, + "max": 0.05, + "step": 0.001, + "description": "How fast the nodes return to their original position." + }, + { + "key": "opacity", + "label": "Line Opacity", + "type": "slider", + "min": 0.05, + "max": 1.0, + "step": 0.05, + "description": "Transparency of the connecting lines." + } + ] } diff --git a/backend/banners/nebula.json b/backend/banners/nebula.json index 85d6eb0..ccfc868 100644 --- a/backend/banners/nebula.json +++ b/backend/banners/nebula.json @@ -9,5 +9,60 @@ "attractionForce": 0.25, "cloudSize": 14, "returnSpeed": 0.02 - } + }, + "schema": [ + { + "key": "particleCount", + "label": "Particle Count", + "type": "number", + "min": 10, + "max": 1000, + "description": "Number of gas blobs in the nebula. Higher values increase density but may impact performance." + }, + { + "key": "speed", + "label": "Drift Speed", + "type": "slider", + "min": 0.001, + "max": 0.05, + "step": 0.001, + "description": "How fast the nebula drifts and pulses on its own." + }, + { + "key": "opacity", + "label": "Overall Opacity", + "type": "slider", + "min": 0.1, + "max": 1.0, + "step": 0.05, + "description": "Transparency of the nebula clouds." + }, + { + "key": "attractionForce", + "label": "Click Attraction", + "type": "slider", + "min": 0.05, + "max": 1.0, + "step": 0.05, + "description": "How strongly the gas condenses when you click and hold." + }, + { + "key": "cloudSize", + "label": "Cloud Size", + "type": "slider", + "min": 5, + "max": 50, + "step": 1, + "description": "The radius of individual gas blobs. Larger values create a thicker, smudgier cloud." + }, + { + "key": "returnSpeed", + "label": "Flow Back Speed", + "type": "slider", + "min": 0.001, + "max": 0.1, + "step": 0.001, + "description": "How quickly the cloud returns to its original shape after being dispersed." + } + ] } diff --git a/frontend/src/components/BannerSettingsDialog.tsx b/frontend/src/components/BannerSettingsDialog.tsx index 684e507..7713921 100644 --- a/frontend/src/components/BannerSettingsDialog.tsx +++ b/frontend/src/components/BannerSettingsDialog.tsx @@ -1,11 +1,23 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, - TextField, Box, Typography, Alert + TextField, Box, Typography, Alert, Slider, Stack, + Tooltip, IconButton } from '@mui/material'; +import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; import { useTranslation } from 'react-i18next'; import api from '../api'; +interface BannerSettingSchema { + key: string; + label: string; + type: 'number' | 'slider' | 'text' | 'boolean'; + min?: number; + max?: number; + step?: number; + description?: string; +} + interface Banner { id: number; name: string; @@ -13,6 +25,7 @@ interface Banner { description: string; is_active: boolean; settings: any; + schema: BannerSettingSchema[]; } interface BannerSettingsDialogProps { @@ -24,13 +37,13 @@ interface BannerSettingsDialogProps { const BannerSettingsDialog: React.FC = ({ open, onClose, banner, onSave }) => { const { t } = useTranslation(); - const [settingsStr, setSettingsStr] = useState(''); + const [localSettings, setLocalSettings] = useState({}); const [error, setError] = useState(''); const [saving, setSaving] = useState(false); - React.useEffect(() => { + useEffect(() => { if (banner) { - setSettingsStr(JSON.stringify(banner.settings, null, 2)); + setLocalSettings(banner.settings || {}); setError(''); } }, [banner]); @@ -39,54 +52,105 @@ const BannerSettingsDialog: React.FC = ({ open, onClo if (!banner) return; try { - const parsedSettings = JSON.parse(settingsStr); setSaving(true); - await api.patch(`banners/${banner.id}/`, { settings: parsedSettings }); + await api.patch(`banners/${banner.id}/`, { settings: localSettings }); onSave(); onClose(); } catch (err: any) { - setError(err instanceof SyntaxError ? 'Invalid JSON format' : (err.response?.data?.error || 'Failed to save settings')); + setError(err.response?.data?.error || 'Failed to save settings'); } finally { setSaving(false); } }; + const updateSetting = (key: string, value: any) => { + setLocalSettings((prev: any) => ({ + ...prev, + [key]: value + })); + }; + + const renderField = (schema: BannerSettingSchema) => { + const value = localSettings[schema.key] ?? ''; + + return ( + + + + {schema.label} + + {schema.description && ( + + + + + + )} + + + {schema.type === 'slider' ? ( + + updateSetting(schema.key, val)} + valueLabelDisplay="auto" + /> + + ) : schema.type === 'number' ? ( + updateSetting(schema.key, Number(e.target.value))} + /> + ) : ( + updateSetting(schema.key, e.target.value)} + /> + )} + + {schema.description && ( + + {schema.description} + + )} + + ); + }; + return ( - - Edit Settings: {banner?.name} + + Banner Settings: {banner?.name} - - - - Modify the JSON configuration for this banner model. Changes take effect immediately for all users. + + {error && {error}} + + {banner?.schema && banner.schema.length > 0 ? ( + + {banner.schema.map(field => renderField(field))} + + ) : ( + + No dynamic schema defined for this banner. Please use the JSON editor or check the banner configuration files. - - {error && {error}} - - setSettingsStr(e.target.value)} - placeholder='{ "key": "value" }' - sx={{ - '& .MuiInputBase-root': { - fontFamily: 'monospace', - fontSize: '0.85rem' - } - }} - /> - + )} - + diff --git a/frontend/src/pages/AdminDashboard.tsx b/frontend/src/pages/AdminDashboard.tsx index 7afd594..ce80e60 100644 --- a/frontend/src/pages/AdminDashboard.tsx +++ b/frontend/src/pages/AdminDashboard.tsx @@ -50,6 +50,7 @@ interface Banner { description: string; is_active: boolean; settings: any; + schema: any[]; } const AdminDashboard: React.FC = () => {