Compare commits

...
13 Commits
Author SHA1 Message Date
Ross 3b920e819e . 2025-12-05 14:42:25 +00:00
Ross df7aa67ce8 . 2025-12-05 14:09:44 +00:00
Ross 132aac6edd . 2025-12-05 14:02:32 +00:00
Ross 1f155f0d56 . 2025-12-05 13:56:12 +00:00
Ross c71c229ea8 . 2025-12-05 13:53:08 +00:00
Ross 94fd98a612 . 2025-12-05 13:47:02 +00:00
Ross e5fa66d622 . 2025-12-05 13:36:18 +00:00
Ross bb8298b18c p 2025-12-05 13:33:34 +00:00
Ross 78240090f1 . 2025-12-05 12:08:08 +00:00
Ross d55cd821b5 . 2025-12-05 11:35:22 +00:00
Ross 4ce91f6390 . 2025-12-05 11:06:21 +00:00
Ross 09be93e8b6 Refactor code structure for improved readability and maintainability 2025-12-05 10:52:27 +00:00
Ross bf9e90d754 . 2025-12-05 10:46:16 +00:00
54 changed files with 1154 additions and 48 deletions
+25 -5
View File
@@ -7,7 +7,7 @@ class DinosaurForm(forms.ModelForm):
model = Dinosaur model = Dinosaur
fields = [ fields = [
'name', 'era', 'diet', 'description', 'image', 'name', 'era', 'diet', 'description', 'image',
'speed', 'weight', 'intelligence', 'height', 'background', 'speed', 'weight', 'intelligence', 'height',
'facts', 'facts',
] ]
widgets = { widgets = {
@@ -16,10 +16,11 @@ class DinosaurForm(forms.ModelForm):
'diet': forms.Select(attrs={'class': 'input'}), 'diet': forms.Select(attrs={'class': 'input'}),
'description': forms.Textarea(attrs={'class': 'textarea', 'rows': 3}), 'description': forms.Textarea(attrs={'class': 'textarea', 'rows': 3}),
'image': forms.ClearableFileInput(attrs={'class': 'file-input'}), 'image': forms.ClearableFileInput(attrs={'class': 'file-input'}),
'speed': forms.NumberInput(attrs={'class': 'input', 'min': 0, 'max': 100}), 'background': forms.Select(attrs={'class': 'input'}),
'weight': forms.NumberInput(attrs={'class': 'input', 'min': 0, 'max': 100}), 'speed': forms.NumberInput(attrs={'class': 'input', 'min': 0, 'max': int(Dinosaur.SPEED_MAX_KMH), 'placeholder': 'km/h'}),
'intelligence': forms.NumberInput(attrs={'class': 'input', 'min': 0, 'max': 100}), 'weight': forms.NumberInput(attrs={'class': 'input', 'min': 0, 'max': int(Dinosaur.WEIGHT_MAX_KG), 'placeholder': 'kg'}),
'height': forms.NumberInput(attrs={'class': 'input', 'min': 0, 'max': 100}), 'intelligence': forms.NumberInput(attrs={'class': 'input', 'min': 0, 'max': 100, 'placeholder': '0-100 score'}),
'height': forms.NumberInput(attrs={'class': 'input', 'min': 0, 'max': int(Dinosaur.HEIGHT_MAX_M), 'step': '0.1', 'placeholder': 'metres'}),
} }
# Add facts field widget # Add facts field widget
@@ -38,3 +39,22 @@ class BackgroundImageForm(forms.ModelForm):
'image': forms.ClearableFileInput(attrs={'class': 'file-input'}), 'image': forms.ClearableFileInput(attrs={'class': 'file-input'}),
'is_active': forms.CheckboxInput(attrs={'class': 'checkbox'}), 'is_active': forms.CheckboxInput(attrs={'class': 'checkbox'}),
} }
from django.forms import modelformset_factory
# A simplified form used in the bulk edit table (only a few editable fields)
class DinosaurBulkForm(forms.ModelForm):
class Meta:
model = Dinosaur
fields = ['name', 'background', 'speed', 'weight', 'height', 'intelligence']
widgets = {
'name': forms.TextInput(attrs={'class': 'input'}),
'background': forms.Select(attrs={'class': 'input'}),
'speed': forms.NumberInput(attrs={'class': 'input', 'min': 0}),
'weight': forms.NumberInput(attrs={'class': 'input', 'min': 0}),
'height': forms.NumberInput(attrs={'class': 'input', 'step': '0.1'}),
'intelligence': forms.NumberInput(attrs={'class': 'input', 'min': 0, 'max': 100}),
}
DinosaurBulkFormSet = modelformset_factory(Dinosaur, form=DinosaurBulkForm, extra=0)
@@ -0,0 +1,61 @@
from django.db import migrations, models
def forwards(apps, schema_editor):
Dinosaur = apps.get_model('cards', 'Dinosaur')
# Constants must match those used in models at conversion time
SPEED_MAX_KMH = 60.0
WEIGHT_MAX_KG = 15000.0
HEIGHT_MAX_M = 12.0
for obj in Dinosaur.objects.all():
try:
# previous values were 0-100 percentages
old_speed = getattr(obj, 'speed', None)
old_weight = getattr(obj, 'weight', None)
old_height = getattr(obj, 'height', None)
if old_speed is not None:
new_speed = int(round(float(old_speed) / 100.0 * SPEED_MAX_KMH))
obj.speed = new_speed
if old_weight is not None:
new_weight = int(round(float(old_weight) / 100.0 * WEIGHT_MAX_KG))
obj.weight = new_weight
if old_height is not None:
new_height = round(float(old_height) / 100.0 * HEIGHT_MAX_M, 1)
# assign as string to avoid Decimal conversion issues
obj.height = new_height
obj.save(update_fields=['speed', 'weight', 'height'])
except Exception:
# skip errors during migration for safety; admins can fix manually
continue
def reverse(apps, schema_editor):
# Not reversible automatically
pass
class Migration(migrations.Migration):
dependencies = [
('cards', '0003_dinosaur_facts'),
]
operations = [
migrations.AlterField(
model_name='dinosaur',
name='speed',
field=models.PositiveIntegerField(default=30, help_text='Speed in km/h'),
),
migrations.AlterField(
model_name='dinosaur',
name='weight',
field=models.PositiveIntegerField(default=1000, help_text='Weight in kg'),
),
migrations.AlterField(
model_name='dinosaur',
name='height',
field=models.DecimalField(default=1.5, max_digits=5, decimal_places=1, help_text='Height in metres'),
),
migrations.RunPython(forwards, reverse_code=migrations.RunPython.noop),
]
@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cards', '0004_convert_stats_to_units'),
]
operations = [
migrations.AddField(
model_name='dinosaur',
name='background',
field=models.ForeignKey(blank=True, help_text='Optional per-card background image', null=True, on_delete=models.SET_NULL, related_name='dinosaurs', to='cards.backgroundimage'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2025-12-05 10:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cards', '0004_convert_stats_to_units'),
]
operations = [
migrations.AlterField(
model_name='dinosaur',
name='intelligence',
field=models.PositiveSmallIntegerField(default=50, help_text='Score 0-100'),
),
]
@@ -0,0 +1,14 @@
# Generated by Django 6.0 on 2025-12-05 10:39
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('cards', '0005_add_background_fk'),
('cards', '0005_alter_dinosaur_intelligence'),
]
operations = [
]
+137 -5
View File
@@ -19,12 +19,24 @@ class Dinosaur(models.Model):
diet = models.CharField(max_length=32, choices=DIET_CHOICES, default='herbivore') diet = models.CharField(max_length=32, choices=DIET_CHOICES, default='herbivore')
description = models.TextField(blank=True) description = models.TextField(blank=True)
image = models.ImageField(upload_to='dinosaurs/', blank=True, null=True) image = models.ImageField(upload_to='dinosaurs/', blank=True, null=True)
background = models.ForeignKey(
'cards.BackgroundImage',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='dinosaurs',
help_text='Optional per-card background image'
)
# Top-trump style stats (0-100) # Stats stored in real-world units now:
speed = models.PositiveSmallIntegerField(default=50) # - `speed` in km/h
weight = models.PositiveSmallIntegerField(default=50) # - `weight` in kg
intelligence = models.PositiveSmallIntegerField(default=50) # - `height` in metres
height = models.PositiveSmallIntegerField(default=50) # - `intelligence` remains a 0-100 score
speed = models.PositiveIntegerField(default=30, help_text='Speed in km/h')
weight = models.PositiveIntegerField(default=1000, help_text='Weight in kg')
intelligence = models.PositiveSmallIntegerField(default=50, help_text='Score 0-100')
height = models.DecimalField(max_digits=5, decimal_places=1, default=1.5, help_text='Height in metres')
facts = models.TextField(blank=True, help_text='One fact per line; shown as bullet points on the card') facts = models.TextField(blank=True, help_text='One fact per line; shown as bullet points on the card')
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
@@ -42,6 +54,126 @@ class Dinosaur(models.Model):
return [] return []
return [f.strip() for f in self.facts.splitlines() if f.strip()] return [f.strip() for f in self.facts.splitlines() if f.strip()]
# -- Unit helpers / display properties -------------------------------------------------
# These map the 0-100 top-trump style values to human-friendly units. The numeric
# mappings are conservative defaults and can be adjusted if you want different ranges.
# Ranges used to convert between stored unit values and the 0-100 bar/star scale.
SPEED_MAX_KMH = 60.0
WEIGHT_MAX_KG = 15000.0
HEIGHT_MAX_M = 12.0
# Display helpers (use stored unit values directly)
@property
def speed_display(self):
return f"{self.speed} km/h"
@property
def weight_display(self):
kg = int(self.weight)
if kg >= 1000:
tonnes = round(kg / 1000.0, 1)
return f"{tonnes} t"
return f"{kg} kg"
@property
def height_display(self):
# height is a DecimalField; format to one decimal place
return f"{float(self.height):.1f} m"
@property
def intelligence_display(self):
return f"{self.intelligence}%"
# Percent helpers: convert stored units back to a 0-100 scale for bars/stars
def speed_percent(self):
return min(100, int(round(self.speed / float(self.SPEED_MAX_KMH) * 100)))
def weight_percent(self):
return min(100, int(round(self.weight / float(self.WEIGHT_MAX_KG) * 100)))
def height_percent(self):
return min(100, int(round(float(self.height) / float(self.HEIGHT_MAX_M) * 100)))
def intelligence_percent(self):
return min(100, int(self.intelligence or 0))
# -- Star rating helpers -------------------------------------------------------------
def _star_rating_from_percent(self, percent):
"""Convert a 0-100 percent into a 0-5 star score in 0.5 increments."""
if percent is None:
return 0.0
val = float(percent) * 0.05 # scale 0-100 -> 0-5
# round to nearest 0.5
return round(val * 2) / 2.0
def _stars_string(self, percent):
"""Return a simple star string like '★★★★½' and trailing empty stars ''."""
stars = self._star_rating_from_percent(percent)
full = int(stars)
half = 1 if (stars - full) >= 0.5 else 0
empty = 5 - full - half
return "" * full + ("½" if half else "") + "" * empty
def _star_types_from_percent(self, percent):
"""Return a list of five items: 'full', 'half', or 'empty'."""
stars = self._star_rating_from_percent(percent)
full = int(stars)
half = 1 if (stars - full) >= 0.5 else 0
types = ['full'] * full
if half:
types.append('half')
types.extend(['empty'] * (5 - len(types)))
return types
# Expose star strings and original values for templates
@property
def speed_stars(self):
return self._stars_string(self.speed_percent())
@property
def speed_star_types(self):
return self._star_types_from_percent(self.speed_percent())
@property
def speed_orig(self):
return self.speed
@property
def weight_stars(self):
return self._stars_string(self.weight_percent())
@property
def weight_star_types(self):
return self._star_types_from_percent(self.weight_percent())
@property
def weight_orig(self):
return self.weight
@property
def height_stars(self):
return self._stars_string(self.height_percent())
@property
def height_star_types(self):
return self._star_types_from_percent(self.height_percent())
@property
def height_orig(self):
return self.height
@property
def intelligence_stars(self):
return self._stars_string(self.intelligence_percent())
@property
def intelligence_star_types(self):
return self._star_types_from_percent(self.intelligence_percent())
@property
def intelligence_orig(self):
return self.intelligence
class BackgroundImage(models.Model): class BackgroundImage(models.Model):
"""Background images used behind card renders. """Background images used behind card renders.
+34 -5
View File
@@ -1,8 +1,8 @@
<article class="card-item" id="card-{{ card.pk }}"> <article class="card-item" id="card-{{ card.pk }}">
<div class="toptrump-card"> <div class="toptrump-card">
<div class="hero-image" style="background-image: url('{% if background and background.image %}{{ background.image.url }}{% endif %}');"> <div class="hero-image" style="background-image: url('{% if card.background and card.background.image %}{{ card.background.image.url }}{% elif background and background.image %}{{ background.image.url }}{% endif %}');">
{% if card.image %} {% if card.image %}
<img src="{{ card.image.url }}" alt="{{ card.name }}" style="display:block;margin:16px auto 0; max-width:72%; height:auto; object-fit:cover; box-shadow:0 4px 12px rgba(0,0,0,0.3); border-radius:6px;"> <img class="card-hero-img" src="{{ card.image.url }}" alt="{{ card.name }}">
{% endif %} {% endif %}
</div> </div>
<div class="title-band">{{ card.name }}</div> <div class="title-band">{{ card.name }}</div>
@@ -16,9 +16,38 @@
</ul> </ul>
{% endif %} {% endif %}
<div style="margin-top:0.5rem"> <div style="margin-top:0.5rem">
<div class="stat-row"><div class="stat-label">Speed</div><div class="stat-bar"><div class="stat-fill" style="width:{{ card.speed }}%"></div></div></div> <div class="stat-row">
<div class="stat-row"><div class="stat-label">Weight</div><div class="stat-bar"><div class="stat-fill" style="width:{{ card.weight }}%"></div></div></div> <div class="stat-label">Speed <small class="is-size-7">(km/h)</small></div>
<div class="stat-row"><div class="stat-label">Intelligence</div><div class="stat-bar"><div class="stat-fill" style="width:{{ card.intelligence }}%"></div></div></div> <div class="stat-bar"><div class="stat-fill" style="width:{{ card.speed_percent }}%"></div></div>
<div class="stat-value" style="margin-left:8px;font-weight:600">
<div>{{ card.speed_display }}</div>
<div class="is-size-7" style="font-weight:500">
{% for t in card.speed_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}
<span style="margin-left:6px">(orig: {{ card.speed_orig }})</span>
</div>
</div>
</div>
<div class="stat-row">
<div class="stat-label">Weight <small class="is-size-7">(kg)</small></div>
<div class="stat-bar"><div class="stat-fill" style="width:{{ card.weight_percent }}%"></div></div>
<div class="stat-value" style="margin-left:8px;font-weight:600">
<div>{{ card.weight_display }}</div>
<div class="is-size-7" style="font-weight:500">
{% for t in card.weight_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}
<span style="margin-left:6px">(orig: {{ card.weight_orig }})</span>
</div>
</div>
</div>
<div class="stat-row">
<div class="stat-label">Intelligence <small class="is-size-7">(score)</small></div>
<div class="stat-bar"><div class="stat-fill" style="width:{{ card.intelligence_percent }}%"></div></div>
<div class="stat-value" style="margin-left:8px;font-weight:600">
<div class="is-size-7" style="font-weight:600">
{% for t in card.intelligence_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}
</div>
<div class="is-size-7" style="font-weight:500">(orig: {{ card.intelligence_orig }})</div>
</div>
</div>
</div> </div>
<div class="buttons" style="margin-top:0.6rem"> <div class="buttons" style="margin-top:0.6rem">
<button class="button is-small is-info" hx-get="{% url 'cards:edit' card.pk %}" hx-target="#htmx-form" hx-swap="innerHTML">Edit</button> <button class="button is-small is-info" hx-get="{% url 'cards:edit' card.pk %}" hx-target="#htmx-form" hx-swap="innerHTML">Edit</button>
+34 -6
View File
@@ -7,9 +7,9 @@
<div style="display:flex; justify-content:center;"> <div style="display:flex; justify-content:center;">
<div class="toptrump-card"> <div class="toptrump-card">
{# Hero / image banner using background image if available #} {# Hero / image banner using background image if available #}
<div class="hero-image" style="background-image: url('{% if background and background.image %}{{ background.image.url }}{% endif %}');"> <div class="hero-image" style="background-image: url('{% if card.background and card.background.image %}{{ card.background.image.url }}{% elif background and background.image %}{{ background.image.url }}{% endif %}');">
{% if card.image %} {% if card.image %}
<img src="{{ card.image.url }}" alt="{{ card.name }}" style="position:relative; display:block; margin:18px auto 0; max-width:78%; height:auto; object-fit:contain; box-shadow:0 6px 18px rgba(0,0,0,0.35); border-radius:6px;"> <img class="card-hero-img" src="{{ card.image.url }}" alt="{{ card.name }}">
{% endif %} {% endif %}
</div> </div>
<div class="title-band">{{ card.name }}</div> <div class="title-band">{{ card.name }}</div>
@@ -29,10 +29,38 @@
{% endif %} {% endif %}
<div style="margin-top:0.8rem"> <div style="margin-top:0.8rem">
<div class="stat-row"><div class="stat-label">Speed</div><div class="stat-bar"><div class="stat-fill" style="width:{{ card.speed }}%"></div></div><div style="width:36px;text-align:right;font-weight:700">{{ card.speed }}</div></div> <div class="stat-row">
<div class="stat-row"><div class="stat-label">Weight</div><div class="stat-bar"><div class="stat-fill" style="width:{{ card.weight }}%"></div></div><div style="width:36px;text-align:right;font-weight:700">{{ card.weight }}</div></div> <div class="stat-label">Speed <small class="is-size-7">(km/h)</small></div>
<div class="stat-row"><div class="stat-label">Intelligence</div><div class="stat-bar"><div class="stat-fill" style="width:{{ card.intelligence }}%"></div></div><div style="width:36px;text-align:right;font-weight:700">{{ card.intelligence }}</div></div> <div class="stat-bar"><div class="stat-fill" style="width:{{ card.speed_percent }}%"></div></div>
<div class="stat-row"><div class="stat-label">Height</div><div class="stat-bar"><div class="stat-fill" style="width:{{ card.height }}%"></div></div><div style="width:36px;text-align:right;font-weight:700">{{ card.height }}</div></div> <div class="stat-value" style="margin-left:8px;font-weight:700">
<div>{{ card.speed_display }}</div>
<div class="is-size-7" style="font-weight:500">{% for t in card.speed_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %} <span style="margin-left:6px">(orig: {{ card.speed_orig }})</span></div>
</div>
</div>
<div class="stat-row">
<div class="stat-label">Weight <small class="is-size-7">(kg)</small></div>
<div class="stat-bar"><div class="stat-fill" style="width:{{ card.weight_percent }}%"></div></div>
<div class="stat-value" style="margin-left:8px;font-weight:700">
<div>{{ card.weight_display }}</div>
<div class="is-size-7" style="font-weight:500">{% for t in card.weight_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %} <span style="margin-left:6px">(orig: {{ card.weight_orig }})</span></div>
</div>
</div>
<div class="stat-row">
<div class="stat-label">Intelligence <small class="is-size-7">(score)</small></div>
<div class="stat-bar"><div class="stat-fill" style="width:{{ card.intelligence_percent }}%"></div></div>
<div class="stat-value" style="margin-left:8px;font-weight:700">
<div class="is-size-7" style="font-weight:600">{% for t in card.intelligence_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}</div>
<div class="is-size-7" style="font-weight:500">(orig: {{ card.intelligence_orig }})</div>
</div>
</div>
<div class="stat-row">
<div class="stat-label">Height <small class="is-size-7">(m)</small></div>
<div class="stat-bar"><div class="stat-fill" style="width:{{ card.height_percent }}%"></div></div>
<div class="stat-value" style="margin-left:8px;font-weight:700">
<div>{{ card.height_display }}</div>
<div class="is-size-7" style="font-weight:500">{% for t in card.height_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %} <span style="margin-left:6px">(orig: {{ card.height_orig }})</span></div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
+83 -5
View File
@@ -3,7 +3,7 @@
<form method="post" enctype="multipart/form-data" action="{% url 'cards:edit' card.pk %}" hx-post="{% url 'cards:edit' card.pk %}" hx-target="#card-{{ card.pk }}" hx-swap="outerHTML"> <form method="post" enctype="multipart/form-data" action="{% url 'cards:edit' card.pk %}" hx-post="{% url 'cards:edit' card.pk %}" hx-target="#card-{{ card.pk }}" hx-swap="outerHTML">
{% else %} {% else %}
{# Create: insert the returned card into the grid, and let the view also send an OOB form fragment to replace the form container when needed #} {# Create: insert the returned card into the grid, and let the view also send an OOB form fragment to replace the form container when needed #}
<form method="post" enctype="multipart/form-data" action="{% url 'cards:create' %}" hx-post="{% url 'cards:create' %}" hx-target="#cards-list .grid" hx-swap="afterbegin"> <form method="post" enctype="multipart/form-data" action="{% url 'cards:create' %}" hx-post="{% url 'cards:create' %}" hx-target="#cards-list .columns" hx-swap="afterbegin">
{% endif %} {% endif %}
{% csrf_token %} {% csrf_token %}
{{ form.non_field_errors }} {{ form.non_field_errors }}
@@ -61,6 +61,44 @@
</div> </div>
</div> </div>
</div> </div>
<div class="field">
<label class="label">{{ form.background.label }}</label>
<div class="control">
<div style="display:none">{{ form.background }}</div>
<div class="background-picker">
<div class="bg-options">
<div class="bg-option" data-bg-id="" role="button" tabindex="0">Use site default</div>
{% for bg in backgrounds %}
<div class="bg-option{% if form.instance.background and form.instance.background.pk == bg.pk %} selected{% endif %}" data-bg-id="{{ bg.pk }}" title="{{ bg.title|default:'Background' }}">
<img src="{{ bg.image.url }}" alt="{{ bg.title }}">
</div>
{% endfor %}
</div>
<p class="help">Optional: choose a background image to use for this card.</p>
</div>
</div>
</div>
<script>
(function(){
const script = document.currentScript;
const root = script && script.parentElement ? script.parentElement : document;
const picker = root.querySelector('.background-picker');
if(!picker) return;
const select = root.querySelector('select[name="background"]');
const options = picker.querySelectorAll('.bg-option');
function clearSelected(){ options.forEach(o=>o.classList.remove('selected')); }
options.forEach(function(opt){
opt.addEventListener('click', function(){
const id = opt.getAttribute('data-bg-id');
clearSelected();
opt.classList.add('selected');
if(select){ select.value = id || '';
select.dispatchEvent(new Event('change', { bubbles: true }));
}
});
});
})();
</script>
<script> <script>
(function(){ (function(){
// Attach to file inputs within this form fragment and update the .file-name text // Attach to file inputs within this form fragment and update the .file-name text
@@ -76,6 +114,18 @@
if(!label) return; if(!label) return;
if(fi.files.length === 0) label.textContent = 'No file chosen'; if(fi.files.length === 0) label.textContent = 'No file chosen';
else label.textContent = fi.files.length > 1 ? fi.files.length + ' files selected' : fi.files[0].name; else label.textContent = fi.files.length > 1 ? fi.files.length + ' files selected' : fi.files[0].name;
// Prefill the name input from the filename if the name is empty
try {
const nameInput = formRoot.querySelector('input[name="name"]');
if(nameInput && (!nameInput.value || nameInput.value.trim() === '')){
const f = fi.files && fi.files[0];
if(f && f.name){
const base = f.name.replace(/\.[^/.]+$/, '');
const title = base.replace(/[_-]+/g, ' ').split(/\s+/).map(function(w){ return w.length? (w.charAt(0).toUpperCase() + w.slice(1)) : ''; }).join(' ').trim();
if(title) nameInput.value = title;
}
}
} catch(e){ /* ignore */ }
}); });
// Make sure clicking the visual label triggers the input if not already clickable // Make sure clicking the visual label triggers the input if not already clickable
const outerLabel = fi.closest('.file').querySelector('.file-label'); const outerLabel = fi.closest('.file').querySelector('.file-label');
@@ -93,19 +143,47 @@
<div class="field-body"> <div class="field-body">
<div class="field"> <div class="field">
<label class="label">{{ form.speed.label }}</label> <label class="label">{{ form.speed.label }}</label>
<div class="control">{{ form.speed }}</div> <div class="control">
<div class="field has-addons">
<div class="control is-expanded">{{ form.speed }}</div>
<div class="control">
<a class="button is-static">km/h</a>
</div>
</div>
</div>
</div> </div>
<div class="field"> <div class="field">
<label class="label">{{ form.weight.label }}</label> <label class="label">{{ form.weight.label }}</label>
<div class="control">{{ form.weight }}</div> <div class="control">
<div class="field has-addons">
<div class="control is-expanded">{{ form.weight }}</div>
<div class="control">
<a class="button is-static">kg</a>
</div>
</div>
</div>
</div> </div>
<div class="field"> <div class="field">
<label class="label">{{ form.intelligence.label }}</label> <label class="label">{{ form.intelligence.label }}</label>
<div class="control">{{ form.intelligence }}</div> <div class="control">
<div class="field has-addons">
<div class="control is-expanded">{{ form.intelligence }}</div>
<div class="control">
<a class="button is-static">score</a>
</div>
</div>
</div>
</div> </div>
<div class="field"> <div class="field">
<label class="label">{{ form.height.label }}</label> <label class="label">{{ form.height.label }}</label>
<div class="control">{{ form.height }}</div> <div class="control">
<div class="field has-addons">
<div class="control is-expanded">{{ form.height }}</div>
<div class="control">
<a class="button is-static">m</a>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
+60 -1
View File
@@ -4,6 +4,8 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>TopTrumps - Dinosaurs</title> <title>TopTrumps - Dinosaurs</title>
<!-- Small inline favicon to avoid browser requesting /favicon.ico and spamming 404s -->
<link rel="icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
<script src="https://unpkg.com/htmx.org@1.9.5"></script> <script src="https://unpkg.com/htmx.org@1.9.5"></script>
<style> <style>
@@ -33,7 +35,39 @@
#theme-toggle{margin-left:0.5rem} #theme-toggle{margin-left:0.5rem}
/* Top Trumps card styles */ /* Top Trumps card styles */
.toptrump-card{max-width:320px;border-radius:10px;overflow:hidden;background:var(--card-bg);color:var(--text);box-shadow:0 6px 18px rgba(0,0,0,0.12);border:1px solid rgba(0,0,0,0.06)} .toptrump-card{max-width:320px;border-radius:10px;overflow:hidden;background:var(--card-bg);color:var(--text);box-shadow:0 6px 18px rgba(0,0,0,0.12);border:1px solid rgba(0,0,0,0.06)}
.toptrump-card .hero-image{height:180px;background-size:cover;background-position:center} .toptrump-card .hero-image{height:180px;background-size:cover;background-position:center;position:relative;overflow:hidden}
/* Hero image: make the dinosaur image larger, cover the banner and remove shadow */
.toptrump-card .hero-image img.card-hero-img{
position:absolute;
left:50%;
top:50%;
transform:translate(-50%,-50%);
/* ensure the image fills the banner while keeping aspect ratio */
min-width:110%;
min-height:110%;
width:auto;
height:auto;
object-fit:cover;
border-radius:6px;
box-shadow:none;
}
/* Star rating visuals (app-level) */
.star { font-size:0.95rem; display:inline-block; line-height:1; margin-left:2px; }
.star.full { color:#ffdd57; }
.star.empty { color:rgba(255,255,255,0.25); }
.star.half { color:rgba(255,255,255,0.25); position:relative; display:inline-block; }
.star.half::before { content: '★'; color:#ffdd57; position:absolute; left:0; top:0; width:50%; overflow:hidden; display:inline-block; }
.stat-value { text-align:right; min-width:6.5rem }
/* Background picker thumbnails (app-level) */
.background-picker{margin-top:0.5rem}
.background-picker .bg-options{display:flex;gap:0.5rem;flex-wrap:wrap}
.background-picker .bg-option{width:64px;height:48px;border:1px solid rgba(0,0,0,0.06);display:flex;align-items:center;justify-content:center;cursor:pointer;background:#fff;color:#222;padding:4px;border-radius:4px}
.background-picker .bg-option img{width:100%;height:100%;object-fit:cover;display:block;border-radius:2px}
.background-picker .bg-option.selected{outline:2px solid var(--accent);box-shadow:0 2px 6px rgba(0,0,0,0.08)}
.background-picker .bg-option[role="button"]{font-size:0.8rem;padding:6px 8px}
/* Bulk edit thumbnails */
.bulk-thumb{width:48px;height:48px;object-fit:cover;border-radius:6px;border:1px solid rgba(0,0,0,0.06);display:inline-block}
.bulk-thumb.empty{background:rgba(0,0,0,0.04);width:48px;height:48px;border-radius:6px}
.toptrump-card .title-band{background:var(--accent);color:#072027;padding:0.6rem 0.75rem;text-align:center;font-weight:700;font-size:1.05rem} .toptrump-card .title-band{background:var(--accent);color:#072027;padding:0.6rem 0.75rem;text-align:center;font-weight:700;font-size:1.05rem}
.toptrump-card .card-body{padding:0.75rem} .toptrump-card .card-body{padding:0.75rem}
.toptrump-card .stat-row{display:flex;align-items:center;gap:0.75rem;padding:0.25rem 0} .toptrump-card .stat-row{display:flex;align-items:center;gap:0.75rem;padding:0.25rem 0}
@@ -85,4 +119,29 @@
} }
})(); })();
</script> </script>
<script>
// Ensure HTMX requests include Django CSRF token (reads from cookie)
(function(){
function getCookie(name){
let cookieValue = null;
if (document.cookie && document.cookie !== ''){
const cookies = document.cookie.split(';');
for (let i=0;i<cookies.length;i++){
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')){
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
document.body.addEventListener('htmx:configRequest', function(evt){
const csrftoken = getCookie('csrftoken');
if (csrftoken){
evt.detail.headers['X-CSRFToken'] = csrftoken;
}
});
})();
</script>
</html> </html>
+155
View File
@@ -0,0 +1,155 @@
{% extends "cards/base.html" %}
{% block content %}
<section class="section">
<h2 class="title is-4">Cards overview — bulk edit</h2>
<p class="subtitle is-6">Edit multiple cards inline and save changes in one go.</p>
<style>
/* Allow inputs inside the table to wrap on narrow viewports */
.table-container .field.has-addons{white-space:normal}
.table-container .control.is-expanded{width:100%}
.table-container input.input{min-width:100px}
.table-container td{padding-left: 0.1rem; padding-right: 0.1rem;}
/* Ensure thumbnails don't shrink layout unexpectedly */
.bulk-thumb{flex:none}
/* Make this page full-bleed: allow the app container to use the full viewport width */
.app-container{max-width:none; width:100%; padding-left:0.5rem; padding-right:0.5rem}
/* Remove extra section padding so content touches edges if needed */
section.section{padding-left:0; padding-right:0}
/* Table container: allow horizontal scroll and make table use full available width */
.table-container{width:100%; overflow-x:auto}
.table-container .table{min-width:1100px}
</style>
<div style="margin-bottom:1rem">
<form method="post" action="{% url 'cards:overview_create_images' %}" enctype="multipart/form-data" style="display:inline-block; margin-right:1rem">
{% csrf_token %}
<div class="file has-name">
<label class="file-label">
<input class="file-input" type="file" name="images" multiple>
<span class="file-cta"><span class="file-icon">📁</span><span class="file-label">Select images…</span></span>
</label>
</div>
<button class="button is-small is-link" type="submit">Create cards from images</button>
</form>
</div>
<form method="post" action="{% url 'cards:overview_bulk_update' %}">
{% csrf_token %}
<div class="table-container">
<table class="table is-fullwidth is-striped">
<thead>
<tr>
<th>Preview</th>
<th>Name</th>
<th>Background</th>
<th>Speed</th>
<th>Weight</th>
<th>Height</th>
<th>Intel</th>
<th></th>
</tr>
</thead>
<tbody>
{% if formset %}
{{ formset.management_form }}
{% for form in formset.forms %}
{{ form.id }}
<tr>
<td>
{% if form.instance.image %}
<img class="bulk-thumb" src="{{ form.instance.image.url }}" alt="{{ form.instance.name }}">
{% else %}
<div class="bulk-thumb empty"></div>
{% endif %}
</td>
<td>{{ form.name }}</td>
<td>{{ form.background }}</td>
<td style="width:90px">
<div class="field has-addons">
<div class="control is-expanded">{{ form.speed }}</div>
<div class="control"><a class="button is-static">km/h</a></div>
</div>
</td>
<td style="width:90px">
<div class="field has-addons">
<div class="control is-expanded">{{ form.weight }}</div>
<div class="control"><a class="button is-static">kg</a></div>
</div>
</td>
<td style="width:90px">
<div class="field has-addons">
<div class="control is-expanded">{{ form.height }}</div>
<div class="control"><a class="button is-static">m</a></div>
</div>
</td>
<td style="width:90px">
<div class="field has-addons">
<div class="control is-expanded">{{ form.intelligence }}</div>
<div class="control"><a class="button is-static">score</a></div>
</div>
</td>
<td style="width:90px">ID: {{ form.instance.pk }}</td>
</tr>
{% endfor %}
{% else %}
{% for card in cards %}
<tr>
<td>
{% if card.image %}
<img class="bulk-thumb" src="{{ card.image.url }}" alt="{{ card.name }}">
{% else %}
<div class="bulk-thumb empty"></div>
{% endif %}
</td>
<td><input class="input" name="name-{{ card.pk }}" value="{{ card.name }}"></td>
<td>
<div class="select"><select name="background-{{ card.pk }}">
<option value="">Site default</option>
{% for bg in backgrounds %}
<option value="{{ bg.pk }}"{% if card.background and card.background.pk == bg.pk %} selected{% endif %}>{{ bg.title|default:'Background' }}</option>
{% endfor %}
</select></div>
</td>
<td style="width:90px">
<div class="field has-addons">
<div class="control is-expanded"><input class="input" name="speed-{{ card.pk }}" value="{{ card.speed }}"></div>
<div class="control"><a class="button is-static">km/h</a></div>
</div>
</td>
<td style="width:90px">
<div class="field has-addons">
<div class="control is-expanded"><input class="input" name="weight-{{ card.pk }}" value="{{ card.weight }}"></div>
<div class="control"><a class="button is-static">kg</a></div>
</div>
</td>
<td style="width:90px">
<div class="field has-addons">
<div class="control is-expanded"><input class="input" name="height-{{ card.pk }}" value="{{ card.height }}"></div>
<div class="control"><a class="button is-static">m</a></div>
</div>
</td>
<td style="width:90px">
<div class="field has-addons">
<div class="control is-expanded"><input class="input" name="intelligence-{{ card.pk }}" value="{{ card.intelligence }}"></div>
<div class="control"><a class="button is-static">score</a></div>
</div>
</td>
<td>ID: {{ card.pk }}</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
<div class="field">
<div class="control">
<button class="button is-primary" type="submit">Save changes</button>
<a class="button is-light" href="{% url 'cards:list' %}">Back to list</a>
</div>
</div>
</form>
</section>
{% endblock %}
+55
View File
@@ -9,11 +9,66 @@
<div class="level-item"> <div class="level-item">
<a class="button is-light" href="{% url 'cards:backgrounds' %}">Manage backgrounds</a> <a class="button is-light" href="{% url 'cards:backgrounds' %}">Manage backgrounds</a>
</div> </div>
<div class="level-item">
<a class="button is-light" href="{% url 'cards:overview' %}">Bulk edit</a>
</div>
</div> </div>
</div> </div>
<div id="htmx-form"></div> <div id="htmx-form"></div>
<form id="cards-filter" method="get" hx-get="{% url 'cards:list' %}" hx-target="#cards-list" hx-swap="innerHTML" hx-trigger="change delay:250ms">
<div class="field has-addons">
<div class="control">
<input class="input" type="search" name="q" placeholder="Search name…" value="{{ current_q }}">
</div>
<div class="control">
<div class="select">
<select name="era">
<option value="all" {% if current_era == 'all' %}selected{% endif %}>All eras</option>
{% for code,label in eras %}
<option value="{{ code }}" {% if current_era == code %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="control">
<div class="select">
<select name="diet">
<option value="all" {% if current_diet == 'all' %}selected{% endif %}>All diets</option>
{% for code,label in diets %}
<option value="{{ code }}" {% if current_diet == code %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="control">
<div class="select">
<select name="sort">
<option value="" {% if not current_sort %}selected{% endif %}>Sort (default)</option>
<option value="name" {% if current_sort == 'name' %}selected{% endif %}>Name</option>
<option value="created" {% if current_sort == 'created' %}selected{% endif %}>Newest</option>
<option value="speed" {% if current_sort == 'speed' %}selected{% endif %}>Speed</option>
<option value="weight" {% if current_sort == 'weight' %}selected{% endif %}>Weight</option>
<option value="height" {% if current_sort == 'height' %}selected{% endif %}>Height</option>
<option value="intelligence" {% if current_sort == 'intelligence' %}selected{% endif %}>Intelligence</option>
</select>
</div>
</div>
<div class="control">
<div class="select">
<select name="order">
<option value="asc" {% if current_order == 'asc' %}selected{% endif %}>Asc</option>
<option value="desc" {% if current_order == 'desc' %}selected{% endif %}>Desc</option>
</select>
</div>
</div>
<div class="control">
<button class="button is-info" type="submit">Apply</button>
</div>
</div>
</form>
<div id="cards-list"> <div id="cards-list">
{% include 'cards/_cards_list.html' %} {% include 'cards/_cards_list.html' %}
</div> </div>
+3
View File
@@ -13,4 +13,7 @@ urlpatterns = [
path('backgrounds/create/', views.background_create, name='background_create'), path('backgrounds/create/', views.background_create, name='background_create'),
path('backgrounds/<int:pk>/activate/', views.background_activate, name='background_activate'), path('backgrounds/<int:pk>/activate/', views.background_activate, name='background_activate'),
path('backgrounds/<int:pk>/delete/', views.background_delete, name='background_delete'), path('backgrounds/<int:pk>/delete/', views.background_delete, name='background_delete'),
path('overview/', views.cards_overview, name='overview'),
path('overview/bulk-update/', views.cards_bulk_update, name='overview_bulk_update'),
path('overview/create-images/', views.cards_bulk_create_images, name='overview_create_images'),
] ]
+130 -10
View File
@@ -1,7 +1,9 @@
from django.shortcuts import render, get_object_or_404, redirect from django.shortcuts import render, get_object_or_404, redirect
from django.http import HttpResponse, HttpResponseBadRequest from django.http import HttpResponse, HttpResponseBadRequest
from django.template.loader import render_to_string from django.template.loader import render_to_string
import os
from .models import Dinosaur, BackgroundImage from .models import Dinosaur, BackgroundImage
from .forms import DinosaurBulkFormSet
from .forms import DinosaurForm from .forms import DinosaurForm
from .forms import BackgroundImageForm from .forms import BackgroundImageForm
@@ -11,46 +13,111 @@ def is_htmx(request):
def card_list(request): def card_list(request):
cards = Dinosaur.objects.all() # Basic filtering/search/sort via GET params so the UI can request
# updated lists using HTMX or a regular browser GET.
qs = Dinosaur.objects.all()
# Search by name
q = (request.GET.get('q') or '').strip()
if q:
qs = qs.filter(name__icontains=q)
# Filter by era and diet
era = request.GET.get('era')
if era and era != 'all':
qs = qs.filter(era=era)
diet = request.GET.get('diet')
if diet and diet != 'all':
qs = qs.filter(diet=diet)
# Sorting: allow a small whitelist of fields to avoid unexpected orders
sort_map = {
'name': 'name',
'created': 'created_at',
'speed': 'speed',
'weight': 'weight',
'height': 'height',
'intelligence': 'intelligence',
}
sort_by = request.GET.get('sort')
order = request.GET.get('order', 'asc')
if sort_by in sort_map:
field = sort_map[sort_by]
if order == 'desc':
field = '-' + field
qs = qs.order_by(field)
background = BackgroundImage.get_active() if BackgroundImage.objects.exists() else None background = BackgroundImage.get_active() if BackgroundImage.objects.exists() else None
context = {
'cards': qs,
'background': background,
'eras': Dinosaur.ERA_CHOICES,
'diets': Dinosaur.DIET_CHOICES,
'current_q': q,
'current_era': era or 'all',
'current_diet': diet or 'all',
'current_sort': sort_by or '',
'current_order': order,
}
if is_htmx(request): if is_htmx(request):
return render(request, 'cards/_cards_list.html', {'cards': cards, 'background': background}) return render(request, 'cards/_cards_list.html', context)
return render(request, 'cards/list.html', {'cards': cards, 'background': background}) return render(request, 'cards/list.html', context)
def card_create(request): def card_create(request):
background = BackgroundImage.get_active() if BackgroundImage.objects.exists() else None background = BackgroundImage.get_active() if BackgroundImage.objects.exists() else None
backgrounds = BackgroundImage.objects.all()
if request.method == 'POST': if request.method == 'POST':
form = DinosaurForm(request.POST, request.FILES) form = DinosaurForm(request.POST, request.FILES)
if form.is_valid(): if form.is_valid():
card = form.save() # Save instance with commit=False so we can set a default name
# from the uploaded image filename when the name is empty.
card = form.save(commit=False)
if not card.name:
image_file = form.cleaned_data.get('image')
if image_file and getattr(image_file, 'name', None):
base = os.path.splitext(image_file.name)[0]
# replace separators and title-case for a nicer default
card.name = base.replace('_', ' ').replace('-', ' ').strip().title()
card.save()
if is_htmx(request): if is_htmx(request):
card_html = render_to_string('cards/_card_item.html', {'card': card, 'background': background}, request=request) card_html = render_to_string('cards/_card_item.html', {'card': card, 'background': background}, request=request)
# Also return an out-of-band fragment to replace/clear the form container # Also return an out-of-band fragment to replace/clear the form container
form_html = render_to_string('cards/_form.html', {'form': DinosaurForm()}, request=request) form_html = render_to_string('cards/_form.html', {'form': DinosaurForm(), 'background': background, 'backgrounds': backgrounds}, request=request)
oob = f'<div id="htmx-form" hx-swap-oob="true">{form_html}</div>' oob = f'<div id="htmx-form" hx-swap-oob="true">{form_html}</div>'
return HttpResponse(card_html + oob) return HttpResponse(card_html + oob)
return redirect('cards:list') return redirect('cards:list')
else: else:
if is_htmx(request): if is_htmx(request):
# Return the form as an out-of-band fragment so it updates the form container # Return the form as an out-of-band fragment so it updates the form container
form_html = render_to_string('cards/_form.html', {'form': form}, request=request) form_html = render_to_string('cards/_form.html', {'form': form, 'background': background, 'backgrounds': backgrounds}, request=request)
oob = f'<div id="htmx-form" hx-swap-oob="true">{form_html}</div>' oob = f'<div id="htmx-form" hx-swap-oob="true">{form_html}</div>'
return HttpResponse(oob) return HttpResponse(oob)
else: else:
form = DinosaurForm() form = DinosaurForm()
return render(request, 'cards/_form.html', {'form': form, 'background': background}) return render(request, 'cards/_form.html', {'form': form, 'background': background, 'backgrounds': backgrounds})
def card_edit(request, pk): def card_edit(request, pk):
card = get_object_or_404(Dinosaur, pk=pk) card = get_object_or_404(Dinosaur, pk=pk)
background = BackgroundImage.get_active() if BackgroundImage.objects.exists() else None background = BackgroundImage.get_active() if BackgroundImage.objects.exists() else None
backgrounds = BackgroundImage.objects.all()
if request.method == 'POST': if request.method == 'POST':
form = DinosaurForm(request.POST, request.FILES, instance=card) form = DinosaurForm(request.POST, request.FILES, instance=card)
if form.is_valid(): if form.is_valid():
card = form.save() # Save with commit=False to allow setting a default name from an
# uploaded image if the instance has no name.
card = form.save(commit=False)
if not card.name:
image_file = form.cleaned_data.get('image')
if image_file and getattr(image_file, 'name', None):
base = os.path.splitext(image_file.name)[0]
card.name = base.replace('_', ' ').replace('-', ' ').strip().title()
card.save()
if is_htmx(request): if is_htmx(request):
card_html = render_to_string('cards/_card_item.html', {'card': card, 'background': background}, request=request) card_html = render_to_string('cards/_card_item.html', {'card': card, 'background': background}, request=request)
# Clear the form container via OOB so the edit form disappears after successful save # Clear the form container via OOB so the edit form disappears after successful save
@@ -59,13 +126,13 @@ def card_edit(request, pk):
return redirect('cards:list') return redirect('cards:list')
else: else:
if is_htmx(request): if is_htmx(request):
form_html = render_to_string('cards/_form.html', {'form': form, 'card': card}, request=request) form_html = render_to_string('cards/_form.html', {'form': form, 'card': card, 'background': background, 'backgrounds': backgrounds}, request=request)
oob = f'<div id="htmx-form" hx-swap-oob="true">{form_html}</div>' oob = f'<div id="htmx-form" hx-swap-oob="true">{form_html}</div>'
return HttpResponse(oob) return HttpResponse(oob)
else: else:
form = DinosaurForm(instance=card) form = DinosaurForm(instance=card)
return render(request, 'cards/_form.html', {'form': form, 'card': card, 'background': background}) return render(request, 'cards/_form.html', {'form': form, 'card': card, 'background': background, 'backgrounds': backgrounds})
def card_delete(request, pk): def card_delete(request, pk):
@@ -141,3 +208,56 @@ def preview_card(request, pk):
if is_htmx(request): if is_htmx(request):
return render(request, 'cards/_card_preview.html', {'card': card, 'background': background}) return render(request, 'cards/_card_preview.html', {'card': card, 'background': background})
return render(request, 'cards/preview.html', {'card': card, 'background': background}) return render(request, 'cards/preview.html', {'card': card, 'background': background})
def cards_overview(request):
"""Overview page showing all cards in a table for quick edits."""
backgrounds = BackgroundImage.objects.all()
qs = Dinosaur.objects.order_by('name')
# Use a modelformset so the template renders the management form
formset = DinosaurBulkFormSet(queryset=qs)
return render(request, 'cards/bulk_edit.html', {'formset': formset, 'backgrounds': backgrounds})
def cards_bulk_update(request):
"""Handle POST from the bulk edit formset and save changes."""
if request.method != 'POST':
# If someone visits the bulk-update URL with GET, redirect them back
# to the overview page instead of returning a 400 Bad Request.
return redirect('cards:overview')
formset = None
qs = Dinosaur.objects.order_by('name')
try:
# Pass the same queryset so management/initial forms align with the rendered formset
formset = DinosaurBulkFormSet(request.POST, queryset=qs)
except Exception:
return HttpResponseBadRequest('Invalid submission')
if formset.is_valid():
formset.save()
return redirect('cards:overview')
# If invalid, re-render the overview with errors
backgrounds = BackgroundImage.objects.all()
return render(request, 'cards/bulk_edit.html', {'formset': formset, 'backgrounds': backgrounds})
def cards_bulk_create_images(request):
"""Create multiple Dinosaur cards from uploaded images (one card per image).
Expects a multipart POST with one or more files in the `images` field.
The card `name` is derived from the filename if not provided.
"""
if request.method != 'POST':
return HttpResponseBadRequest('Only POST allowed')
files = request.FILES.getlist('images')
if not files:
return HttpResponseBadRequest('No images uploaded')
created = []
for f in files:
# derive a friendly name from filename
base = os.path.splitext(getattr(f, 'name', '') or '')[0]
name = base.replace('_', ' ').replace('-', ' ').strip().title() or 'New Dinosaur'
d = Dinosaur(name=name, image=f)
d.save()
created.append(d)
# Redirect back to overview
return redirect('cards:overview')
BIN
View File
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+45 -5
View File
@@ -1,8 +1,8 @@
<article class="card-item" id="card-{{ card.pk }}"> <article class="card-item" id="card-{{ card.pk }}">
<div class="toptrump-card"> <div class="toptrump-card">
<div class="hero-image" style="background-image: url('{% if background and background.image %}{{ background.image.url }}{% endif %}');"> <div class="hero-image" style="background-image: url('{% if card.background and card.background.image %}{{ card.background.image.url }}{% elif background and background.image %}{{ background.image.url }}{% endif %}');">
{% if card.image %} {% if card.image %}
<img src="{{ card.image.url }}" alt="{{ card.name }}" style="display:block;margin:16px auto 0; max-width:72%; height:auto; object-fit:cover; box-shadow:0 4px 12px rgba(0,0,0,0.3); border-radius:6px;"> <img class="card-hero-img" src="{{ card.image.url }}" alt="{{ card.name }}">
{% endif %} {% endif %}
</div> </div>
<div class="title-band">{{ card.name }}</div> <div class="title-band">{{ card.name }}</div>
@@ -16,9 +16,49 @@
</ul> </ul>
{% endif %} {% endif %}
<div style="margin-top:0.5rem"> <div style="margin-top:0.5rem">
<div class="stat-row"><div class="stat-label">Speed</div><div class="stat-bar"><div class="stat-fill" style="width:{{ card.speed }}%"></div></div></div> <div class="stat-row">
<div class="stat-row"><div class="stat-label">Weight</div><div class="stat-bar"><div class="stat-fill" style="width:{{ card.weight }}%"></div></div></div> <div class="stat-label">Speed <small class="is-size-7">(km/h)</small></div>
<div class="stat-row"><div class="stat-label">Intelligence</div><div class="stat-bar"><div class="stat-fill" style="width:{{ card.intelligence }}%"></div></div></div> <div class="stat-bar"><div class="stat-fill" style="width:{{ card.speed_percent }}%"></div></div>
<div class="stat-value" style="margin-left:8px;font-weight:600">
<div>{{ card.speed_display }}</div>
<div class="is-size-7" style="font-weight:500">
{% for t in card.speed_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}
<span style="margin-left:6px">(orig: {{ card.speed_orig }})</span>
</div>
</div>
</div>
<div class="stat-row">
<div class="stat-label">Weight <small class="is-size-7">(kg)</small></div>
<div class="stat-bar"><div class="stat-fill" style="width:{{ card.weight_percent }}%"></div></div>
<div class="stat-value" style="margin-left:8px;font-weight:600">
<div>{{ card.weight_display }}</div>
<div class="is-size-7" style="font-weight:500">
{% for t in card.weight_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}
<span style="margin-left:6px">(orig: {{ card.weight_orig }})</span>
</div>
</div>
</div>
<div class="stat-row">
<div class="stat-label">Height <small class="is-size-7">(m)</small></div>
<div class="stat-bar"><div class="stat-fill" style="width:{{ card.height_percent }}%"></div></div>
<div class="stat-value" style="margin-left:8px;font-weight:600">
<div>{{ card.height_display }}</div>
<div class="is-size-7" style="font-weight:500">
{% for t in card.height_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}
<span style="margin-left:6px">(orig: {{ card.height_orig }})</span>
</div>
</div>
</div>
<div class="stat-row">
<div class="stat-label">Intelligence <small class="is-size-7">(score)</small></div>
<div class="stat-bar"><div class="stat-fill" style="width:{{ card.intelligence_percent }}%"></div></div>
<div class="stat-value" style="margin-left:8px;font-weight:600">
<div class="is-size-7" style="font-weight:600">
{% for t in card.intelligence_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}
</div>
<div class="is-size-7" style="font-weight:500">(orig: {{ card.intelligence_orig }})</div>
</div>
</div>
</div> </div>
<div class="buttons" style="margin-top:0.6rem"> <div class="buttons" style="margin-top:0.6rem">
<button class="button is-small is-info" hx-get="{% url 'cards:edit' card.pk %}" hx-target="#htmx-form" hx-swap="innerHTML">Edit</button> <button class="button is-small is-info" hx-get="{% url 'cards:edit' card.pk %}" hx-target="#htmx-form" hx-swap="innerHTML">Edit</button>
+83 -5
View File
@@ -2,7 +2,7 @@
{% if card %} {% if card %}
<form method="post" enctype="multipart/form-data" action="{% url 'cards:edit' card.pk %}" hx-post="{% url 'cards:edit' card.pk %}" hx-target="#card-{{ card.pk }}" hx-swap="outerHTML"> <form method="post" enctype="multipart/form-data" action="{% url 'cards:edit' card.pk %}" hx-post="{% url 'cards:edit' card.pk %}" hx-target="#card-{{ card.pk }}" hx-swap="outerHTML">
{% else %} {% else %}
<form method="post" enctype="multipart/form-data" action="{% url 'cards:create' %}" hx-post="{% url 'cards:create' %}" hx-target="#cards-list .grid" hx-swap="afterbegin"> <form method="post" enctype="multipart/form-data" action="{% url 'cards:create' %}" hx-post="{% url 'cards:create' %}" hx-target="#cards-list .columns" hx-swap="afterbegin">
{% endif %} {% endif %}
{% csrf_token %} {% csrf_token %}
{{ form.non_field_errors }} {{ form.non_field_errors }}
@@ -60,6 +60,45 @@
</div> </div>
</div> </div>
</div> </div>
<div class="field">
<label class="label">{{ form.background.label }}</label>
<div class="control">
<div style="display:none">{{ form.background }}</div>
<div class="background-picker">
<div class="bg-options">
<div class="bg-option" data-bg-id="" role="button" tabindex="0">Use site default</div>
{% for bg in backgrounds %}
<div class="bg-option{% if form.instance.background and form.instance.background.pk == bg.pk %} selected{% endif %}" data-bg-id="{{ bg.pk }}" title="{{ bg.title|default:'Background' }}">
<img src="{{ bg.image.url }}" alt="{{ bg.title }}">
</div>
{% endfor %}
</div>
<p class="help">Optional: choose a background image to use for this card.</p>
</div>
</div>
</div>
<script>
(function(){
const script = document.currentScript;
const root = script && script.parentElement ? script.parentElement : document;
const picker = root.querySelector('.background-picker');
if(!picker) return;
const select = root.querySelector('select[name="background"]');
const options = picker.querySelectorAll('.bg-option');
function clearSelected(){ options.forEach(o=>o.classList.remove('selected')); }
options.forEach(function(opt){
opt.addEventListener('click', function(){
const id = opt.getAttribute('data-bg-id');
clearSelected();
opt.classList.add('selected');
if(select){ select.value = id || '';
// dispatch change for any listeners
select.dispatchEvent(new Event('change', { bubbles: true }));
}
});
});
})();
</script>
<script> <script>
(function(){ (function(){
const script = document.currentScript; const script = document.currentScript;
@@ -73,6 +112,17 @@
if(!label) return; if(!label) return;
if(fi.files.length === 0) label.textContent = 'No file chosen'; if(fi.files.length === 0) label.textContent = 'No file chosen';
else label.textContent = fi.files.length > 1 ? fi.files.length + ' files selected' : fi.files[0].name; else label.textContent = fi.files.length > 1 ? fi.files.length + ' files selected' : fi.files[0].name;
try {
const nameInput = formRoot.querySelector('input[name="name"]');
if(nameInput && (!nameInput.value || nameInput.value.trim() === '')){
const f = fi.files && fi.files[0];
if(f && f.name){
const base = f.name.replace(/\.[^/.]+$/, '');
const title = base.replace(/[_-]+/g, ' ').split(/\s+/).map(function(w){ return w.length? (w.charAt(0).toUpperCase() + w.slice(1)) : ''; }).join(' ').trim();
if(title) nameInput.value = title;
}
}
}catch(e){ /* ignore UI enhancement errors */ }
}); });
const outerLabel = fi.closest('.file').querySelector('.file-label'); const outerLabel = fi.closest('.file').querySelector('.file-label');
if(outerLabel && !outerLabel.contains(fi)){ if(outerLabel && !outerLabel.contains(fi)){
@@ -86,19 +136,47 @@
<div class="field-body"> <div class="field-body">
<div class="field"> <div class="field">
<label class="label">{{ form.speed.label }}</label> <label class="label">{{ form.speed.label }}</label>
<div class="control">{{ form.speed }}</div> <div class="control">
<div class="field has-addons">
<div class="control is-expanded">{{ form.speed }}</div>
<div class="control">
<a class="button is-static">km/h</a>
</div>
</div>
</div>
</div> </div>
<div class="field"> <div class="field">
<label class="label">{{ form.weight.label }}</label> <label class="label">{{ form.weight.label }}</label>
<div class="control">{{ form.weight }}</div> <div class="control">
<div class="field has-addons">
<div class="control is-expanded">{{ form.weight }}</div>
<div class="control">
<a class="button is-static">kg</a>
</div>
</div>
</div>
</div> </div>
<div class="field"> <div class="field">
<label class="label">{{ form.intelligence.label }}</label> <label class="label">{{ form.intelligence.label }}</label>
<div class="control">{{ form.intelligence }}</div> <div class="control">
<div class="field has-addons">
<div class="control is-expanded">{{ form.intelligence }}</div>
<div class="control">
<a class="button is-static">score</a>
</div>
</div>
</div>
</div> </div>
<div class="field"> <div class="field">
<label class="label">{{ form.height.label }}</label> <label class="label">{{ form.height.label }}</label>
<div class="control">{{ form.height }}</div> <div class="control">
<div class="field has-addons">
<div class="control is-expanded">{{ form.height }}</div>
<div class="control">
<a class="button is-static">m</a>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
+60 -1
View File
@@ -4,6 +4,8 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>TopTrumps - Dinosaurs</title> <title>TopTrumps - Dinosaurs</title>
<!-- Small inline favicon to avoid browser requesting /favicon.ico and spamming 404s -->
<link rel="icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
<script src="https://unpkg.com/htmx.org@1.9.5"></script> <script src="https://unpkg.com/htmx.org@1.9.5"></script>
<style> <style>
@@ -33,7 +35,32 @@
#theme-toggle{margin-left:0.5rem} #theme-toggle{margin-left:0.5rem}
/* Top Trumps card styles */ /* Top Trumps card styles */
.toptrump-card{max-width:320px;border-radius:10px;overflow:hidden;background:var(--card-bg);color:var(--text);box-shadow:0 6px 18px rgba(0,0,0,0.12);border:1px solid rgba(0,0,0,0.06)} .toptrump-card{max-width:320px;border-radius:10px;overflow:hidden;background:var(--card-bg);color:var(--text);box-shadow:0 6px 18px rgba(0,0,0,0.12);border:1px solid rgba(0,0,0,0.06)}
.toptrump-card .hero-image{height:180px;background-size:cover;background-position:center} .toptrump-card .hero-image{height:180px;background-size:cover;background-position:center;position:relative;overflow:hidden}
/* Hero image: make the dinosaur image larger, cover the banner and remove shadow */
.toptrump-card .hero-image img.card-hero-img{
position:absolute;
left:50%;
top:50%;
transform:translate(-50%,-50%);
/* ensure the image fills the banner while keeping aspect ratio */
min-width:110%;
min-height:110%;
width:auto;
height:auto;
object-fit:cover;
border-radius:6px;
box-shadow:none;
}
/* Background picker thumbnails */
.background-picker{margin-top:0.5rem}
.background-picker .bg-options{display:flex;gap:0.5rem;flex-wrap:wrap}
.background-picker .bg-option{width:64px;height:48px;border:1px solid rgba(0,0,0,0.06);display:flex;align-items:center;justify-content:center;cursor:pointer;background:#fff;color:#222;padding:4px;border-radius:4px}
.background-picker .bg-option img{max-width:100%;max-height:100%;display:block;border-radius:2px}
.background-picker .bg-option.selected{outline:2px solid var(--accent);box-shadow:0 2px 6px rgba(0,0,0,0.08)}
.background-picker .bg-option[role="button"]{font-size:0.8rem;padding:6px 8px}
/* Bulk edit thumbnails */
.bulk-thumb{width:48px;height:48px;object-fit:cover;border-radius:6px;border:1px solid rgba(0,0,0,0.06);display:inline-block}
.bulk-thumb.empty{background:rgba(0,0,0,0.04);width:48px;height:48px;border-radius:6px}
.toptrump-card .title-band{background:var(--accent);color:#072027;padding:0.6rem 0.75rem;text-align:center;font-weight:700;font-size:1.05rem} .toptrump-card .title-band{background:var(--accent);color:#072027;padding:0.6rem 0.75rem;text-align:center;font-weight:700;font-size:1.05rem}
.toptrump-card .card-body{padding:0.75rem} .toptrump-card .card-body{padding:0.75rem}
.toptrump-card .stat-row{display:flex;align-items:center;gap:0.75rem;padding:0.25rem 0} .toptrump-card .stat-row{display:flex;align-items:center;gap:0.75rem;padding:0.25rem 0}
@@ -41,6 +68,13 @@
.toptrump-card .stat-bar{flex:1;height:12px;background:rgba(255,255,255,0.06);border-radius:8px;overflow:hidden} .toptrump-card .stat-bar{flex:1;height:12px;background:rgba(255,255,255,0.06);border-radius:8px;overflow:hidden}
.toptrump-card .stat-fill{height:100%;background:linear-gradient(90deg,var(--accent),#ffdd57);} .toptrump-card .stat-fill{height:100%;background:linear-gradient(90deg,var(--accent),#ffdd57);}
.toptrump-card .fact-list{margin-top:0.5rem;padding-left:1rem} .toptrump-card .fact-list{margin-top:0.5rem;padding-left:1rem}
/* Star rating visuals */
.star { font-size:0.95rem; display:inline-block; line-height:1; margin-left:2px; }
.star.full { color:#ffdd57; }
.star.empty { color:rgba(255,255,255,0.25); }
.star.half { color:rgba(255,255,255,0.25); position:relative; display:inline-block; }
.star.half::before { content: '★'; color:#ffdd57; position:absolute; left:0; top:0; width:50%; overflow:hidden; display:inline-block; }
.stat-value { text-align:right; min-width:6.5rem }
</style> </style>
</head> </head>
<body> <body>
@@ -85,4 +119,29 @@
} }
})(); })();
</script> </script>
<script>
// Ensure HTMX requests include Django CSRF token (reads from cookie)
(function(){
function getCookie(name){
let cookieValue = null;
if (document.cookie && document.cookie !== ''){
const cookies = document.cookie.split(';');
for (let i=0;i<cookies.length;i++){
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')){
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
document.body.addEventListener('htmx:configRequest', function(evt){
const csrftoken = getCookie('csrftoken');
if (csrftoken){
evt.detail.headers['X-CSRFToken'] = csrftoken;
}
});
})();
</script>
</html> </html>
+134
View File
@@ -0,0 +1,134 @@
{% extends "cards/base.html" %}
{% block content %}
<section class="section">
<h2 class="title is-4">Cards overview — bulk edit</h2>
<p class="subtitle is-6">Edit multiple cards inline and save changes in one go.</p>
<style>
/* Allow inputs inside the table to wrap on narrow viewports */
.table-container .field.has-addons{white-space:normal}
.table-container .control.is-expanded{width:100%}
.table-container input.input{min-width:50px}
/* Ensure thumbnails don't shrink layout unexpectedly */
.bulk-thumb{flex:none}
/* Make this page full-bleed: allow the app container to use the full viewport width */
.app-container{max-width:none; width:100%; padding-left:0.5rem; padding-right:0.5rem}
/* Remove extra section padding so content touches edges if needed */
section.section{padding-left:0; padding-right:0}
/* Table container: allow horizontal scroll and make table use full available width */
.table-container{width:100%; overflow-x:auto}
.table-container .table{min-width:1100px}
</style>
<div style="margin-bottom:1rem">
<form method="post" action="{% url 'cards:overview_create_images' %}" enctype="multipart/form-data" style="display:inline-block; margin-right:1rem">
{% csrf_token %}
<div class="file has-name">
<label class="file-label">
<input class="file-input" type="file" name="images" multiple>
<span class="file-cta"><span class="file-icon">📁</span><span class="file-label">Select images…</span></span>
</label>
</div>
<button class="button is-small is-link" type="submit">Create cards from images</button>
</form>
</div>
<form method="post" action="{% url 'cards:overview_bulk_update' %}">
{% csrf_token %}
<div class="table-container">
<table class="table is-fullwidth is-striped">
<thead>
<tr>
<th>Preview</th>
<th>Name</th>
<th>Background</th>
<th>Speed</th>
<th>Weight</th>
<th>Height</th>
<th>Intel</th>
<th></th>
</tr>
</thead>
<tbody>
{% if formset %}
{{ formset.management_form }}
{% for form in formset.forms %}
{{ form.id }}
<tr>
<td>
{% if form.instance.image %}
<img class="bulk-thumb" src="{{ form.instance.image.url }}" alt="{{ form.instance.name }}">
{% else %}
<div class="bulk-thumb empty"></div>
{% endif %}
</td>
<td>{{ form.name }}</td>
<td>{{ form.background }}</td>
<td style="width:90px">{{ form.speed }}</td>
<td style="width:90px">{{ form.weight }}</td>
<td style="width:90px">{{ form.height }}</td>
<td style="width:90px">{{ form.intelligence }}</td>
<td style="width:90px">ID: {{ form.instance.pk }}</td>
</tr>
{% endfor %}
{% else %}
{% for card in cards %}
<tr>
<td>
{% if card.image %}
<img class="bulk-thumb" src="{{ card.image.url }}" alt="{{ card.name }}">
{% else %}
<div class="bulk-thumb empty"></div>
{% endif %}
</td>
<td><input class="input" name="name-{{ card.pk }}" value="{{ card.name }}"></td>
<td>
<div class="select"><select name="background-{{ card.pk }}">
<option value="">Site default</option>
{% for bg in backgrounds %}
<option value="{{ bg.pk }}"{% if card.background and card.background.pk == bg.pk %} selected{% endif %}>{{ bg.title|default:'Background' }}</option>
{% endfor %}
</select></div>
</td>
<td style="width:90px">
<div class="field has-addons">
<div class="control is-expanded"><input class="input" name="speed-{{ card.pk }}" value="{{ card.speed }}"></div>
<div class="control"><a class="button is-static">km/h</a></div>
</div>
</td>
<td style="width:90px">
<div class="field has-addons">
<div class="control is-expanded"><input class="input" name="weight-{{ card.pk }}" value="{{ card.weight }}"></div>
<div class="control"><a class="button is-static">kg</a></div>
</div>
</td>
<td style="width:90px">
<div class="field has-addons">
<div class="control is-expanded"><input class="input" name="height-{{ card.pk }}" value="{{ card.height }}"></div>
<div class="control"><a class="button is-static">m</a></div>
</div>
</td>
<td style="width:90px">
<div class="field has-addons">
<div class="control is-expanded"><input class="input" name="intelligence-{{ card.pk }}" value="{{ card.intelligence }}"></div>
<div class="control"><a class="button is-static">score</a></div>
</div>
</td>
<td>ID: {{ card.pk }}</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
<div class="field">
<div class="control">
<button class="button is-primary" type="submit">Save changes</button>
<a class="button is-light" href="{% url 'cards:list' %}">Back to list</a>
</div>
</div>
</form>
</section>
{% endblock %}
+3
View File
@@ -9,6 +9,9 @@
<div class="level-item"> <div class="level-item">
<a class="button is-light" href="{% url 'cards:backgrounds' %}">Manage backgrounds</a> <a class="button is-light" href="{% url 'cards:backgrounds' %}">Manage backgrounds</a>
</div> </div>
<div class="level-item">
<a class="button is-light" href="{% url 'cards:overview' %}">Bulk edit</a>
</div>
</div> </div>
</div> </div>
+4
View File
@@ -16,11 +16,15 @@ Including another URLconf
""" """
from django.contrib import admin from django.contrib import admin
from django.urls import path, include from django.urls import path, include
from django.views.generic import RedirectView
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
# legacy prefixed paths: redirect /cards/overview/ -> /overview/
path('cards/overview/', RedirectView.as_view(url='/overview/', permanent=False)),
path('cards/overview/bulk-update/', RedirectView.as_view(url='/overview/bulk-update/', permanent=False)),
path('', include('cards.urls')), path('', include('cards.urls')),
] ]