mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
made settings page for banner dynamic from the settings json
This commit is contained in:
1 parent
dd67fa4b79
commit
94b98d7077
8 files changed
+218
-34
No files matched your search
@@ -39,16 +39,24 @@ class Command(BaseCommand):
|
||||
# Sync missing settings from JSON to DB without overwriting existing keys
|
||||
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()
|
||||
|
||||
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}"))
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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<BannerSettingsDialogProps> = ({ open, onClose, banner, onSave }) => {
|
||||
const { t } = useTranslation();
|
||||
const [settingsStr, setSettingsStr] = useState('');
|
||||
const [localSettings, setLocalSettings] = useState<any>({});
|
||||
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<BannerSettingsDialogProps> = ({ 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 (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
Edit Settings: {banner?.name}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
|
||||
Modify the JSON configuration for this banner model. Changes take effect immediately for all users.
|
||||
<Box key={schema.key} sx={{ mb: 3 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
|
||||
{schema.label}
|
||||
</Typography>
|
||||
{schema.description && (
|
||||
<Tooltip title={schema.description} arrow>
|
||||
<IconButton size="small" sx={{ p: 0.5 }}>
|
||||
<HelpOutlineIcon sx={{ fontSize: '1rem', opacity: 0.6 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
|
||||
<TextField
|
||||
multiline
|
||||
rows={12}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={settingsStr}
|
||||
onChange={(e) => setSettingsStr(e.target.value)}
|
||||
placeholder='{ "key": "value" }'
|
||||
sx={{
|
||||
'& .MuiInputBase-root': {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.85rem'
|
||||
}
|
||||
}}
|
||||
{schema.type === 'slider' ? (
|
||||
<Box sx={{ px: 1 }}>
|
||||
<Slider
|
||||
value={Number(value)}
|
||||
min={schema.min ?? 0}
|
||||
max={schema.max ?? 100}
|
||||
step={schema.step ?? 1}
|
||||
onChange={(_, val) => updateSetting(schema.key, val)}
|
||||
valueLabelDisplay="auto"
|
||||
/>
|
||||
</Box>
|
||||
) : schema.type === 'number' ? (
|
||||
<TextField
|
||||
type="number"
|
||||
fullWidth
|
||||
size="small"
|
||||
value={value}
|
||||
inputProps={{ min: schema.min, max: schema.max, step: schema.step }}
|
||||
onChange={(e) => updateSetting(schema.key, Number(e.target.value))}
|
||||
/>
|
||||
) : (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
value={value}
|
||||
onChange={(e) => updateSetting(schema.key, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{schema.description && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
||||
{schema.description}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle sx={{ fontWeight: 'bold' }}>
|
||||
Banner Settings: {banner?.name}
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{error && <Alert severity="error" sx={{ mb: 3 }}>{error}</Alert>}
|
||||
|
||||
{banner?.schema && banner.schema.length > 0 ? (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{banner.schema.map(field => renderField(field))}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography color="text.secondary" sx={{ fontStyle: 'italic', py: 2 }}>
|
||||
No dynamic schema defined for this banner. Please use the JSON editor or check the banner configuration files.
|
||||
</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||
<Button onClick={onClose} color="inherit">{t('common.cancel')}</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
variant="contained"
|
||||
disabled={saving}
|
||||
sx={{ px: 4 }}
|
||||
>
|
||||
{saving ? t('common.loading') : t('common.save')}
|
||||
</Button>
|
||||
|
||||
@@ -50,6 +50,7 @@ interface Banner {
|
||||
description: string;
|
||||
is_active: boolean;
|
||||
settings: any;
|
||||
schema: any[];
|
||||
}
|
||||
|
||||
const AdminDashboard: React.FC = () => {
|
||||
|
||||
Reference in new issue
Block a user