Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b920e819e | ||
|
|
df7aa67ce8 | ||
|
|
132aac6edd | ||
|
|
1f155f0d56 | ||
|
|
c71c229ea8 | ||
|
|
94fd98a612 | ||
|
|
e5fa66d622 | ||
|
|
bb8298b18c | ||
|
|
78240090f1 | ||
|
|
d55cd821b5 | ||
|
|
4ce91f6390 | ||
|
|
09be93e8b6 | ||
|
|
bf9e90d754 |
@@ -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 = [
|
||||||
|
]
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 5.1 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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')),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||