Merge branch 'main' into narl.io

This commit is contained in:
nvrl committed 2026-02-18 14:31:33 +01:00
commit 1fcb8e302e
8 files changed
+232 -48

No files matched your search

+19 -11
View File
@@ -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}"))
@@ -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),
),
]
+1
View File
@@ -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)
+1 -1
View File
@@ -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)
+38 -1
View File
@@ -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."
}
]
}
+56 -1
View File
@@ -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 (
<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>
{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>
Edit Settings: {banner?.name}
<DialogTitle sx={{ fontWeight: 'bold' }}>
Banner 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.
<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>
{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'
}
}}
/>
</Box>
)}
</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>
+1
View File
@@ -50,6 +50,7 @@ interface Banner {
description: string;
is_active: boolean;
settings: any;
schema: any[];
}
const AdminDashboard: React.FC = () => {