Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c49ce3bc58 | ||
|
|
6bbc281a9b | ||
|
|
e541e4cc31 | ||
|
|
2dc1631fd2 | ||
|
|
b1364a6eea | ||
|
|
e5d1f1e83f | ||
|
|
7661ad8f00 | ||
|
|
d9c64f34b1 | ||
|
|
626c44796b | ||
|
|
3e748f6080 | ||
|
|
aa4baae500 | ||
|
|
25b80f5dea | ||
|
|
1fb573525b | ||
|
|
cb430859e4 | ||
|
|
83439613bb | ||
|
|
659efda29f | ||
|
|
b67bfc4545 | ||
|
|
ce401fa633 | ||
|
|
81397e8437 | ||
|
|
013063fd61 | ||
|
|
6479eb7446 | ||
|
|
0e4e52fce6 | ||
|
|
c30cb45fc3 | ||
|
|
2663b10ebe | ||
|
|
7c7c78c4fc | ||
|
|
688a04f47a | ||
|
|
9dd761aa41 | ||
|
|
78c7c1d502 | ||
|
|
a4de9ff745 | ||
|
|
d262f7cd3b | ||
|
|
4a2afdc0c7 | ||
|
|
679af14739 | ||
|
|
502f875c4d | ||
|
|
1752d892f5 | ||
|
|
5a7e7f6ad4 | ||
|
|
09922d7019 | ||
|
|
f33a7705d1 | ||
|
|
0e81fe2235 | ||
|
|
3566666cc2 | ||
|
|
346cfa3f5f | ||
|
|
0155fa39b0 | ||
|
|
011a5acce7 | ||
|
|
6e4f41007b | ||
|
|
322c22f14e | ||
|
|
ceea07d0b3 | ||
|
|
5de97c5ee0 | ||
|
|
3b920e819e | ||
|
|
df7aa67ce8 | ||
|
|
132aac6edd | ||
|
|
1f155f0d56 | ||
|
|
c71c229ea8 | ||
|
|
94fd98a612 | ||
|
|
e5fa66d622 | ||
|
|
bb8298b18c | ||
|
|
78240090f1 | ||
|
|
d55cd821b5 | ||
|
|
4ce91f6390 | ||
|
|
09be93e8b6 | ||
|
|
bf9e90d754 |
@@ -7,7 +7,7 @@ class DinosaurForm(forms.ModelForm):
|
||||
model = Dinosaur
|
||||
fields = [
|
||||
'name', 'era', 'diet', 'description', 'image',
|
||||
'speed', 'weight', 'intelligence', 'height',
|
||||
'background', 'speed', 'weight', 'intelligence', 'height',
|
||||
'facts',
|
||||
]
|
||||
widgets = {
|
||||
@@ -16,10 +16,11 @@ class DinosaurForm(forms.ModelForm):
|
||||
'diet': forms.Select(attrs={'class': 'input'}),
|
||||
'description': forms.Textarea(attrs={'class': 'textarea', 'rows': 3}),
|
||||
'image': forms.ClearableFileInput(attrs={'class': 'file-input'}),
|
||||
'speed': forms.NumberInput(attrs={'class': 'input', 'min': 0, 'max': 100}),
|
||||
'weight': forms.NumberInput(attrs={'class': 'input', 'min': 0, 'max': 100}),
|
||||
'intelligence': forms.NumberInput(attrs={'class': 'input', 'min': 0, 'max': 100}),
|
||||
'height': forms.NumberInput(attrs={'class': 'input', 'min': 0, 'max': 100}),
|
||||
'background': forms.Select(attrs={'class': 'input'}),
|
||||
'speed': forms.NumberInput(attrs={'class': 'input', 'min': 0, 'max': int(Dinosaur.SPEED_MAX_KMH), 'placeholder': 'km/h'}),
|
||||
'weight': forms.NumberInput(attrs={'class': 'input', 'min': 0, 'max': int(Dinosaur.WEIGHT_MAX_KG), 'placeholder': 'kg'}),
|
||||
'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
|
||||
@@ -38,3 +39,22 @@ class BackgroundImageForm(forms.ModelForm):
|
||||
'image': forms.ClearableFileInput(attrs={'class': 'file-input'}),
|
||||
'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,91 @@
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
try:
|
||||
import openai
|
||||
except Exception:
|
||||
openai = None
|
||||
|
||||
|
||||
OPENAI_API_KEY = os.environ.get('OPENAI_API_KEY')
|
||||
DEFAULT_MODEL = os.environ.get('CARDS_LLM_MODEL', 'gpt-4o-mini')
|
||||
|
||||
|
||||
PROMPT_TEMPLATE = (
|
||||
"""
|
||||
You are a JSON generator. Given a dinosaur name, return EXACTLY one JSON object matching the schema below.
|
||||
Only return JSON (no explanatory text).
|
||||
|
||||
Schema:
|
||||
{
|
||||
"name": string,
|
||||
"speed_kmh": number|null, // numeric speed in km/h
|
||||
"weight_kg": number|null, // numeric weight in kg
|
||||
"height_m": number|null, // numeric height in meters
|
||||
"intelligence_score": integer|null, // 1-5 scale or null
|
||||
"facts": [string,...],
|
||||
"sources": [{"title":string,"url":string},...]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Use units: km/h, kg, m.
|
||||
- If you cannot find a reliable number, use null.
|
||||
- Provide up to 5 short facts.
|
||||
- Include at least one source object with a URL when available.
|
||||
Name: {name}
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _ensure_openai():
|
||||
if openai is None:
|
||||
raise RuntimeError('openai package not installed; add openai to requirements')
|
||||
if not OPENAI_API_KEY:
|
||||
raise RuntimeError('OPENAI_API_KEY not set in environment')
|
||||
openai.api_key = OPENAI_API_KEY
|
||||
|
||||
|
||||
def _extract_json(text: str) -> Optional[Dict[str, Any]]:
|
||||
text = text.strip()
|
||||
# try direct parse
|
||||
try:
|
||||
return json.loads(text)
|
||||
except Exception:
|
||||
pass
|
||||
# heuristic: find first { ... } block
|
||||
start = text.find('{')
|
||||
end = text.rfind('}')
|
||||
if start != -1 and end != -1 and end > start:
|
||||
try:
|
||||
return json.loads(text[start:end+1])
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def fetch_dinosaur_structured(name: str, model: Optional[str] = None, max_retries: int = 2) -> Dict[str, Any]:
|
||||
"""Call the configured LLM and return parsed JSON per schema.
|
||||
|
||||
Raises RuntimeError on misconfiguration or ValueError if parsing fails.
|
||||
"""
|
||||
model = model or DEFAULT_MODEL
|
||||
if openai is None:
|
||||
raise RuntimeError('openai package not available')
|
||||
_ensure_openai()
|
||||
|
||||
prompt = PROMPT_TEMPLATE.format(name=name)
|
||||
for attempt in range(max_retries + 1):
|
||||
resp = openai.ChatCompletion.create(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0.0,
|
||||
max_tokens=600,
|
||||
)
|
||||
content = resp['choices'][0]['message']['content'].strip()
|
||||
data = _extract_json(content)
|
||||
if data:
|
||||
return data
|
||||
time.sleep(1 + attempt)
|
||||
raise ValueError('Failed to parse LLM response as JSON')
|
||||
@@ -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')
|
||||
description = models.TextField(blank=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)
|
||||
speed = models.PositiveSmallIntegerField(default=50)
|
||||
weight = models.PositiveSmallIntegerField(default=50)
|
||||
intelligence = models.PositiveSmallIntegerField(default=50)
|
||||
height = models.PositiveSmallIntegerField(default=50)
|
||||
# Stats stored in real-world units now:
|
||||
# - `speed` in km/h
|
||||
# - `weight` in kg
|
||||
# - `height` in metres
|
||||
# - `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')
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
@@ -42,6 +54,180 @@ class Dinosaur(models.Model):
|
||||
return []
|
||||
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
|
||||
pct = float(percent)
|
||||
# scale 0-100 -> 0-5
|
||||
val = pct * 0.05
|
||||
# round to nearest 0.5
|
||||
rounded = round(val * 2) / 2.0
|
||||
# Avoid promoting near-100 values to 5.0 via rounding. Only an
|
||||
# exact 100% should map to a full 5.0. Anything less will be
|
||||
# capped at 4.5 to keep 5-star visually reserved for true maxima.
|
||||
if pct >= 100.0:
|
||||
return 5.0
|
||||
return min(rounded, 4.5)
|
||||
|
||||
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
|
||||
|
||||
@property
|
||||
def background_image_scale(self):
|
||||
"""Return a CSS background-size percentage influenced by the
|
||||
dinosaur's stored height. Uses `height_percent()` to derive a
|
||||
value in a reasonable range so larger dinosaurs get a larger
|
||||
background image scale.
|
||||
|
||||
The result is a string like '60%'. Keep the value clamped to
|
||||
avoid extremely small/large sizes.
|
||||
"""
|
||||
try:
|
||||
# Use a combined metric (height + weight) to smooth out extremes
|
||||
h = float(self.height_percent() or 0)
|
||||
w = float(self.weight_percent() or 0)
|
||||
p = (h + w) / 2.0
|
||||
except Exception:
|
||||
p = 50.0
|
||||
# Use much smaller range for subtler effect: largest dinos -> 100%
|
||||
# (full image), smallest -> 140% (mild zoom). Interpolate linearly.
|
||||
min_scale = 100
|
||||
max_scale = 140
|
||||
scale = int(round(min_scale + (1.0 - (p / 100.0)) * (max_scale - min_scale)))
|
||||
scale = max(min_scale, min(max_scale, scale))
|
||||
return f"{scale}%"
|
||||
|
||||
@property
|
||||
def image_scale(self):
|
||||
"""Return a scale factor (float) for the dinosaur's image size.
|
||||
|
||||
Smaller dinosaurs will be scaled down (e.g. 0.6) and larger
|
||||
dinosaurs closer to full size (1.0). Uses `height_percent()`.
|
||||
"""
|
||||
try:
|
||||
# Combine height and weight for a smoother scale value
|
||||
h = float(self.height_percent() or 0)
|
||||
w = float(self.weight_percent() or 0)
|
||||
p = (h + w) / 2.0
|
||||
except Exception:
|
||||
p = 50.0
|
||||
# Subtle scaling: smallest -> 0.90, largest -> 1.00
|
||||
min_scale = 0.90
|
||||
max_scale = 1.00
|
||||
scale = min_scale + (p / 100.0) * (max_scale - min_scale)
|
||||
scale = max(min_scale, min(max_scale, scale))
|
||||
return f"{scale:.2f}"
|
||||
|
||||
|
||||
class BackgroundImage(models.Model):
|
||||
"""Background images used behind card renders.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<div class="column is-3-desktop is-4-tablet is-6-mobile" id="background-{{ bg.pk }}">
|
||||
<article class="box">
|
||||
<form method="post" action="{% url 'cards:background_edit' bg.pk %}" enctype="multipart/form-data" class="background-title-form" hx-post="{% url 'cards:background_edit' bg.pk %}" hx-target="#backgrounds-list" hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
<div style="height:140px; background-image:url('{{ bg.image.url }}'); background-size:cover; background-position:center; border-radius:4px;"></div>
|
||||
<div style="margin-top:0.5rem">
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
{{ form.title }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="is-size-7">Uploaded {{ bg.uploaded_at }}</div>
|
||||
<div class="buttons" style="margin-top:0.5rem">
|
||||
<button class="button is-small is-primary" type="submit">Save</button>
|
||||
<button type="button" class="button is-small" hx-get="{% url 'cards:backgrounds' %}" hx-target="#backgrounds-list" hx-swap="innerHTML">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
</div>
|
||||
@@ -1,19 +1,20 @@
|
||||
<div id="backgrounds-list">
|
||||
<div class="columns is-multiline">
|
||||
{% for bg in backgrounds %}
|
||||
<div class="column is-3-desktop is-4-tablet is-6-mobile">
|
||||
<div class="column is-3-desktop is-4-tablet is-6-mobile" id="background-{{ bg.pk }}">
|
||||
<article class="box">
|
||||
<div style="height:140px; background-image:url('{{ bg.image.url }}'); background-size:cover; background-position:center; border-radius:4px;"></div>
|
||||
<div style="margin-top:0.5rem">
|
||||
<strong>{{ bg.title }}</strong>
|
||||
<div class="is-size-7">Uploaded {{ bg.uploaded_at }}</div>
|
||||
<div class="buttons" style="margin-top:0.5rem">
|
||||
<button class="button is-small" hx-get="{% url 'cards:background_edit' bg.pk %}" hx-target="#background-{{ bg.pk }}" hx-swap="outerHTML">Edit</button>
|
||||
{% if not bg.is_active %}
|
||||
<button class="button is-small" hx-post="{% url 'cards:background_activate' bg.pk %}" hx-swap="outerHTML" hx-target="#backgrounds-list">Set active</button>
|
||||
{% else %}
|
||||
<span class="tag is-success">Active</span>
|
||||
{% endif %}
|
||||
<button class="button is-small is-danger" hx-post="{% url 'cards:background_delete' bg.pk %}" hx-confirm="Delete this background?" hx-swap="none">Delete</button>
|
||||
<button class="button is-small is-danger" hx-post="{% url 'cards:background_delete' bg.pk %}" hx-confirm="Delete this background?" hx-target="#background-{{ bg.pk }}" hx-swap="outerHTML">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
{% comment %} Preview fragment for pasted bulk import data. {% endcomment %}
|
||||
<div class="box">
|
||||
<h4 class="title is-6">Parsed suggestions</h4>
|
||||
{% if preview_items %}
|
||||
<form hx-post="{% url 'cards:import_apply_bulk' %}" hx-target="#import-preview" hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
<textarea name="data" style="display:none">{{ suggestions_json }}</textarea>
|
||||
<table class="table is-fullwidth is-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Match</th>
|
||||
<th>Name / PK</th>
|
||||
<th>Field</th>
|
||||
<th>Current</th>
|
||||
<th>Proposed</th>
|
||||
<th>Apply?</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in preview_items %}
|
||||
{% with s=item.suggestion i=item.index %}
|
||||
{% if item.diffs %}
|
||||
{% for field in item.diffs %}
|
||||
<tr>
|
||||
<td>{% if forloop.first %}#{{ forloop.parentloop.counter }}{% else %} {% endif %}</td>
|
||||
<td>{% if item.matched %}pk:{{ item.matched.pk }}{% elif s.name %}name{% else %}—{% endif %}</td>
|
||||
<td>{{ s.name|default:"(no name)" }}{% if s.pk %} (pk: {{ s.pk }}){% endif %}</td>
|
||||
<td>{{ field }}</td>
|
||||
<td>
|
||||
{% if item.existing %}
|
||||
{% if field == 'facts' %}
|
||||
{% if item.existing.facts %}{{ item.existing.facts|join:"; " }}{% endif %}
|
||||
{% elif field == 'name' %}
|
||||
{{ item.existing.name|default:"—" }}
|
||||
{% elif field == 'era' %}
|
||||
{{ item.existing.era|default:"—" }}
|
||||
{% elif field == 'diet' %}
|
||||
{{ item.existing.diet|default:"—" }}
|
||||
{% elif field == 'speed_kmh' %}
|
||||
{{ item.existing.speed_kmh|default:"—" }}
|
||||
{% elif field == 'weight_kg' %}
|
||||
{{ item.existing.weight_kg|default:"—" }}
|
||||
{% elif field == 'height_m' %}
|
||||
{{ item.existing.height_m|default:"—" }}
|
||||
{% elif field == 'intelligence_score' %}
|
||||
{{ item.existing.intelligence_score|default:"—" }}
|
||||
{% else %}
|
||||
{{ item.existing|default:"—" }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
(new)
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if field == 'facts' %}
|
||||
{% if s.facts %}{% if s.facts|length > 0 %}{{ s.facts|join:"; " }}{% else %}{{ s.facts }}{% endif %}{% endif %}
|
||||
{% elif field == 'name' %}
|
||||
{{ item.proposed.name|default:"" }}
|
||||
{% elif field == 'era' %}
|
||||
{{ item.proposed.era|default:"" }}
|
||||
{% elif field == 'diet' %}
|
||||
{{ item.proposed.diet|default:"" }}
|
||||
{% elif field == 'speed_kmh' %}
|
||||
{{ item.proposed.speed_kmh|default:"" }}
|
||||
{% elif field == 'weight_kg' %}
|
||||
{{ item.proposed.weight_kg|default:"" }}
|
||||
{% elif field == 'height_m' %}
|
||||
{{ item.proposed.height_m|default:"" }}
|
||||
{% elif field == 'intelligence_score' %}
|
||||
{{ item.proposed.intelligence_score|default:"" }}
|
||||
{% else %}
|
||||
{{ item.proposed|default:"" }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" name="apply-{{ i }}-{{ field }}" checked>
|
||||
{% if not item.matched and forloop.first %}
|
||||
<br><label style="font-size:0.85em"><input type="checkbox" name="apply-{{ i }}-create" checked> Create record</label>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td>#{{ forloop.counter }}</td>
|
||||
<td>{% if item.matched %}pk:{{ item.matched.pk }}{% else %}—{% endif %}</td>
|
||||
<td>{{ s.name|default:"(no name)" }}{% if s.pk %} (pk: {{ s.pk }}){% endif %}</td>
|
||||
<td colspan="4"><em>No changes detected</em></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="margin-top:0.5rem">
|
||||
<button class="button is-primary" type="submit">Apply selected changes</button>
|
||||
<button class="button" type="button" onclick="document.getElementById('import-preview').innerHTML=''">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="notification is-warning">No suggestions parsed from the pasted data.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,53 @@
|
||||
<style>
|
||||
/* Shared hero image rules used by both preview (base.html) and printable view.
|
||||
This file no longer forces global card dimensions (those break the list
|
||||
view). When a canonical card size is needed (printable/preview) a
|
||||
`.card-canonical` ancestor or explicit CSS variables should be provided
|
||||
(eg. `--card-width`/`--card-height`). We fall back to sensible defaults
|
||||
so components degrade gracefully. */
|
||||
|
||||
.toptrump-card{ box-sizing:border-box; overflow:hidden; border-radius:8px; background-clip:padding-box; }
|
||||
|
||||
.card-canonical .toptrump-card {
|
||||
width: var(--card-width, 83mm);
|
||||
height: var(--card-height, 130mm);
|
||||
}
|
||||
|
||||
.toptrump-card .hero-image{height:calc(var(--card-height, 130mm) * 0.45); background-size:cover;background-position:center;position:relative;overflow:hidden}
|
||||
|
||||
/* For most designs center the dino image absolutely and allow an inline
|
||||
scale transform (the fragments provide `transform: translate(...) scale(...)`). */
|
||||
.toptrump-card:not(.design-4) .hero-image img.card-hero-img {
|
||||
position:absolute;
|
||||
left:50%;
|
||||
top:50%;
|
||||
transform:translate(-50%,-50%);
|
||||
min-width:110%;
|
||||
min-height:110%;
|
||||
width:auto;
|
||||
height:auto;
|
||||
object-fit:cover;
|
||||
border-radius:6px;
|
||||
box-shadow:none;
|
||||
}
|
||||
|
||||
/* Design-4 uses object-fit:contain and a different layout to avoid
|
||||
horizontal cropping — preserve that behaviour. */
|
||||
.toptrump-card.design-4 .hero-image{ overflow:hidden; display:flex; align-items:center; justify-content:center }
|
||||
.toptrump-card.design-4 .card-hero-img{
|
||||
position:static !important;
|
||||
left:auto !important;
|
||||
top:auto !important;
|
||||
transform:none !important;
|
||||
display:block;
|
||||
margin:0 auto;
|
||||
width:120% !important;
|
||||
max-width:none !important;
|
||||
max-height:120% !important;
|
||||
object-position:center center !important;
|
||||
height:auto;
|
||||
object-fit:contain;
|
||||
}
|
||||
/* Hide print fallback images by default on screen; printable view will reveal them */
|
||||
.print-hero-bg{display:none}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
{# Shared inner fragment for Design 1 - core card markup only #}
|
||||
<div class="toptrump-card design-1" style="border-radius:8px; overflow:hidden; box-shadow:0 6px 18px rgba(0,0,0,0.12);">
|
||||
<div style="padding:8px; display:flex; gap:8px; justify-content:flex-end;"></div>
|
||||
<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 %}'); background-size:cover; background-position:center; position:relative; overflow:hidden;">
|
||||
{# Print fallback: actual <img> element that is hidden on screen but shown by `printable.html` via its print stylesheet. #}
|
||||
{% if card.background and card.background.image or background and background.image %}
|
||||
<img class="print-hero-bg" src="{% if card.background and card.background.image %}{{ card.background.image.url }}{% else %}{{ background.image.url }}{% endif %}" alt="" aria-hidden="true" style="position:absolute; inset:0; width:100%; height:100%; object-fit:cover; z-index:0;">
|
||||
{% endif %}
|
||||
{% if card.image %}
|
||||
<img class="card-hero-img" src="{{ card.image.url }}" alt="{{ card.name }}" style="transform:translate(-50%,-50%) scale({{ card.image_scale }}); max-height:180px; z-index:1;">
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="title-band" style="background:#2a7ae2; color:white; padding:12px; font-weight:700; font-size:1.2rem; text-align:center">{{ card.name }}</div>
|
||||
<div class="card-body" style="padding:12px; background:#fff;">
|
||||
<div class="subtitle is-6"><strong>Era:</strong> {{ card.get_era_display }} — <strong>Diet:</strong>
|
||||
{% if card.diet == 'herbivore' %}
|
||||
<span style="display:inline-block;padding:4px 8px;border-radius:999px;background:#2ecc71;color:#fff;font-weight:700;margin-left:6px;">🥦 Herbivore</span>
|
||||
{% elif card.diet == 'carnivore' %}
|
||||
<span style="display:inline-block;padding:4px 8px;border-radius:999px;background:#e74c3c;color:#fff;font-weight:700;margin-left:6px;">🥩 Carnivore</span>
|
||||
{% else %}
|
||||
<span style="display:inline-block;padding:4px 8px;border-radius:999px;background:#f39c12;color:#fff;font-weight:700;margin-left:6px;">🍽️ Omnivore</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="margin-top:0.5rem">{{ card.description|linebreaksbr }}</div>
|
||||
{% if card.facts_list %}
|
||||
<div style="margin-top:0.5rem">
|
||||
<strong>Fun facts</strong>
|
||||
<ul class="fact-list">
|
||||
{% for fact in card.facts_list %}
|
||||
<li>{{ fact }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="margin-top:0.8rem">
|
||||
{% include 'cards/_card_stats_rows.html' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,38 @@
|
||||
{# Shared inner fragment for Design 2 - core card markup only #}
|
||||
<div class="toptrump-card design-2" style="border-radius:6px; overflow:hidden; background:#111; color:#eee;">
|
||||
<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 %}'); background-size:cover; background-position:center; filter:brightness(0.7); position:relative; overflow:hidden;">
|
||||
{% if card.background and card.background.image or background and background.image %}
|
||||
<img class="print-hero-bg" src="{% if card.background and card.background.image %}{{ card.background.image.url }}{% else %}{{ background.image.url }}{% endif %}" alt="" aria-hidden="true" style="position:absolute; inset:0; width:100%; height:100%; object-fit:cover; z-index:0;">
|
||||
{% endif %}
|
||||
{% if card.image %}
|
||||
<img class="card-hero-img" src="{{ card.image.url }}" alt="{{ card.name }}" style="transform:translate(-50%,-50%) scale({{ card.image_scale }}); max-height:200px; border-radius:6px; box-shadow:0 8px 20px rgba(0,0,0,0.6); z-index:1;">
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="title-band" style="background:#0b2b2b; color:#9fffbf; padding:14px; font-weight:800; font-size:1.3rem; text-align:center">{{ card.name }}</div>
|
||||
<div class="card-body" style="padding:14px; background:#111; color:#eaeaea;">
|
||||
<div class="subtitle is-6"><strong>Era:</strong> {{ card.get_era_display }} — <strong>Diet:</strong>
|
||||
{% if card.diet == 'herbivore' %}
|
||||
<span style="display:inline-block;padding:4px 8px;border-radius:999px;background:#2ecc71;color:#072027;font-weight:700;margin-left:6px;">🥦 Herbivore</span>
|
||||
{% elif card.diet == 'carnivore' %}
|
||||
<span style="display:inline-block;padding:4px 8px;border-radius:999px;background:#e74c3c;color:#072027;font-weight:700;margin-left:6px;">🥩 Carnivore</span>
|
||||
{% else %}
|
||||
<span style="display:inline-block;padding:4px 8px;border-radius:999px;background:#f39c12;color:#072027;font-weight:700;margin-left:6px;">🍽️ Omnivore</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="margin-top:0.5rem">{{ card.description|linebreaksbr }}</div>
|
||||
{% if card.facts_list %}
|
||||
<div style="margin-top:0.5rem">
|
||||
<strong>Fun facts</strong>
|
||||
<ul class="fact-list">
|
||||
{% for fact in card.facts_list %}
|
||||
<li>{{ fact }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="margin-top:0.8rem">
|
||||
{% include 'cards/_card_stats_rows.html' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,65 @@
|
||||
{# Shared inner fragment for Design 3 - core card markup only #}
|
||||
<div class="toptrump-card design-3" style="border-radius:10px; overflow:hidden; box-shadow:0 10px 30px rgba(0,0,0,0.14);">
|
||||
<div class="hero-image" style="background-image: linear-gradient(120deg, rgba(255,122,24,0.12), rgba(175,0,45,0.12)); background-size:cover; background-position:center; filter:brightness(0.95); position:relative; overflow:hidden;">
|
||||
{# diet tint overlay so background colour is visible beneath the gradient and image #}
|
||||
<div class="hero-tint" style="position:absolute; inset:0; z-index:0; {% if card.diet == 'herbivore' %}background-color:#e8f6ea;{% elif card.diet == 'carnivore' %}background-color:#fff0f0;{% elif card.diet == 'omnivore' %}background-color:#fff8e6;{% else %}background-color:#f5f5f5;{% endif %} opacity:0.95;"></div>
|
||||
|
||||
|
||||
{% if card.image %}
|
||||
<img class="card-hero-img" src="{{ card.image.url }}" alt="{{ card.name }}" style="transform:translate(-50%,-50%) scale({{ card.image_scale }}); border-radius:8px; z-index:1;">
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="title-band" style="background:linear-gradient(90deg,#ffd54f,#ff7043); color:#222; padding:12px; font-weight:800; font-size:1.25rem; text-align:center">{{ card.name }}</div>
|
||||
<div class="card-body" style="padding:12px; background:#fff;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; gap:8px;">
|
||||
<div style="white-space:nowrap;"><strong>Era:</strong>
|
||||
<span class="era-badge" style="display:inline-block;padding:2px 6px;border-radius:999px;color:#fff;font-weight:700;margin-left:6px;font-size:0.85rem;">
|
||||
{% if card.era == 'triassic' %}
|
||||
<span style="background:#8e44ad;padding:2px 6px;border-radius:999px;">{{ card.get_era_display }}</span>
|
||||
{% elif card.era == 'jurassic' %}
|
||||
<span style="background:#27ae60;padding:2px 6px;border-radius:999px;">{{ card.get_era_display }}</span>
|
||||
{% elif card.era == 'cretaceous' %}
|
||||
<span style="background:#d35400;padding:2px 6px;border-radius:999px;">{{ card.get_era_display }}</span>
|
||||
{% else %}
|
||||
<span style="background:#7f8c8d;padding:2px 6px;border-radius:999px;">{{ card.get_era_display }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div style="white-space:nowrap;"><strong>Diet:</strong>
|
||||
{% if card.diet == 'herbivore' %}
|
||||
<span style="display:inline-block;padding:2px 6px;border-radius:999px;background:#2ecc71;color:#222;font-weight:700;margin-left:6px;font-size:0.85rem;line-height:1;">🥦 Herbivore</span>
|
||||
{% elif card.diet == 'carnivore' %}
|
||||
<span style="display:inline-block;padding:2px 6px;border-radius:999px;background:#e74c3c;color:#222;font-weight:700;margin-left:6px;font-size:0.85rem;line-height:1;">🥩 Carnivore</span>
|
||||
{% else %}
|
||||
<span style="display:inline-block;padding:2px 6px;border-radius:999px;background:#f39c12;color:#222;font-weight:700;margin-left:6px;font-size:0.85rem;line-height:1;">🍽️ Omnivore</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:0.5rem">{{ card.description|linebreaksbr }}</div>
|
||||
{% if card.facts_list %}
|
||||
<div style="margin-top:0.25rem;">
|
||||
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
|
||||
<span style="background:#ffd54f;color:#222;padding:2px;border-radius:4px;font-weight:700;font-size:0.8rem;line-height:1;">✨</span>
|
||||
<strong style="font-size:0.9rem;line-height:1;">Fun facts</strong>
|
||||
</div>
|
||||
<div style="background:#fbfcfd;border:1px solid rgba(0,0,0,0.04);padding:6px;border-radius:8px;">
|
||||
<ul style="margin:0;padding:0 0 0 0.85rem;list-style:none;display:flex;flex-direction:column;gap:6px;">
|
||||
{% for fact in card.facts_list %}
|
||||
<li style="display:flex;gap:8px;align-items:flex-start;">
|
||||
<span style="color:#ffb74d;font-size:0.95rem;line-height:1;margin-top:1px;">•</span>
|
||||
<span style="flex:1;color:#333;">{{ fact }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="margin-top:0.8rem; display:flex; gap:8px; justify-content:space-around;">
|
||||
<div style="text-align:center"><div style="font-weight:800">{{ card.speed_display }}</div><div class="is-size-7">Speed</div></div>
|
||||
<div style="text-align:center"><div style="font-weight:800">{{ card.weight_display }}</div><div class="is-size-7">Weight</div></div>
|
||||
<div style="text-align:center"><div style="font-weight:800">{{ card.intelligence_display }}</div><div class="is-size-7">Intel</div></div>
|
||||
<div style="text-align:center"><div style="font-weight:800">{{ card.height_display }}</div><div class="is-size-7">Height</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,139 @@
|
||||
{# Shared inner fragment for Design 4 - core two-column card markup only #}
|
||||
<div class="toptrump-card design-4" style="display:flex; gap:12px; border-radius:8px; overflow:hidden; box-shadow:0 8px 24px rgba(0,0,0,0.12);">
|
||||
<div style="flex:0 0 320px; position:relative; background:#f5f5f5; display:flex; flex-direction:column;">
|
||||
<div style="padding:8px 10px; border-bottom:1px solid rgba(0,0,0,0.04); background:linear-gradient(180deg, rgba(255,255,255,0.95), rgba(250,250,250,0.9));">
|
||||
<div style="display:flex; flex-direction:column; align-items:center;">
|
||||
<div style="display:flex; align-items:center; gap:10px; width:100%; justify-content:center;">
|
||||
<div style="flex:0 0 36px; height:6px; background:linear-gradient(90deg,#e5e7eb,#c7d2fe); border-radius:4px; opacity:0.9"></div>
|
||||
<div style="flex:1 1 auto; min-width:0; font-weight:900; font-size:1.15rem; text-transform:uppercase; letter-spacing:0.6px; color:#17202a; text-shadow:0 1px 0 rgba(255,255,255,0.6); text-align:center; word-break:break-word; white-space:normal;">{{ card.name }}</div>
|
||||
<div style="flex:0 0 36px; height:6px; background:linear-gradient(90deg,#ffd6a5,#ffb4a2); border-radius:4px; opacity:0.95"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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 %}'); background-size: {{ card.background_image_scale }}; background-position:center; flex:1; position:relative; padding:8px; overflow:hidden;">
|
||||
{% if card.background and card.background.image or background and background.image %}
|
||||
<img class="print-hero-bg" src="{% if card.background and card.background.image %}{{ card.background.image.url }}{% else %}{{ background.image.url }}{% endif %}" alt="" aria-hidden="true" style="position:absolute; inset:0; width:100%; height:100%; object-fit:cover; z-index:0;">
|
||||
{% endif %}
|
||||
<div style="position:absolute; top:10px; left:50%; transform:translateX(-50%); z-index:3; width:84%; max-width:420px;">
|
||||
<div style="background:rgba(0,0,0,0.55); padding:6px 10px; border-radius:999px; color:#fff; display:flex; align-items:center; justify-content:center;">
|
||||
<div style="width:100%; max-width:360px; display:flex; align-items:center; gap:8px;">
|
||||
<div style="flex:1; height:8px; border-radius:6px; background:linear-gradient(90deg,#eceff1,#f8fafc); position:relative; overflow:visible;">
|
||||
<div style="position:absolute; left:0; right:0; top:0; bottom:0; display:flex;">
|
||||
<div style="flex:1;"><div style="width:100%; height:8px; border-top-left-radius:6px; border-bottom-left-radius:6px; background: {% if card.era == 'triassic' %}#f6d365{% else %}rgba(255,255,255,0.12){% endif %};"></div></div>
|
||||
<div style="flex:1;"><div style="width:100%; height:8px; background: {% if card.era == 'jurassic' %}#8ec5ff{% else %}rgba(255,255,255,0.12){% endif %};"></div></div>
|
||||
<div style="flex:1;"><div style="width:100%; height:8px; border-top-right-radius:6px; border-bottom-right-radius:6px; background: {% if card.era == 'cretaceous' %}#ffd6a5{% else %}rgba(255,255,255,0.12){% endif %};"></div></div>
|
||||
</div>
|
||||
<div style="position:absolute; top:-6px; left:0; right:0; display:flex; justify-content:space-between; pointer-events:none;">
|
||||
<div style="flex:1; display:flex; justify-content:center;"><div style="width:10px; height:10px; border-radius:50%; background:{% if card.era == 'triassic' %}#c47a00{% else %}rgba(255,255,255,0.22){% endif %}; box-shadow:0 2px 6px rgba(0,0,0,0.12);"></div></div>
|
||||
<div style="flex:1; display:flex; justify-content:center;"><div style="width:10px; height:10px; border-radius:50%; background:{% if card.era == 'jurassic' %}#0066cc{% else %}rgba(255,255,255,0.22){% endif %}; box-shadow:0 2px 6px rgba(0,0,0,0.12);"></div></div>
|
||||
<div style="flex:1; display:flex; justify-content:center;"><div style="width:10px; height:10px; border-radius:50%; background:{% if card.era == 'cretaceous' %}#ff7b54{% else %}rgba(255,255,255,0.22){% endif %}; box-shadow:0 2px 6px rgba(0,0,0,0.12);"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-left:10px; font-weight:700; font-size:0.85rem; color:#fff;">{{ card.get_era_display }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="position:absolute; bottom:10px; left:50%; transform:translateX(-50%); z-index:3; background:rgba(255,255,255,0.9); color:#111; padding:6px 10px; border-radius:999px; font-weight:700; font-size:0.85rem; box-shadow:0 6px 18px rgba(0,0,0,0.06);">
|
||||
{% if card.diet == 'herbivore' %}
|
||||
🥦 Herbivore
|
||||
{% elif card.diet == 'carnivore' %}
|
||||
🥩 Carnivore
|
||||
{% else %}
|
||||
🍽️ Omnivore
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if card.image %}
|
||||
<img class="card-hero-img" src="{{ card.image.url }}" alt="{{ card.name }}" style="position:relative; z-index:1; transform: scale({{ card.image_scale }}); transform-origin:center; border-radius:6px; box-shadow:0 6px 18px rgba(0,0,0,0.08); display:block; margin:0 auto; max-height:140px;">
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="padding:8px 10px; border-top:1px solid rgba(0,0,0,0.04); background:#fafafa;">
|
||||
<div style="display:flex; flex-direction:column; gap:6px;">
|
||||
{% with s=card.speed_percent w=card.weight_percent it=card.intelligence_percent h=card.height_percent %}
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; {% if card.is_top_trump and 'speed' in card.best_stats %}background:rgba(26,188,156,0.06); border-left:6px solid #1abc9c; border-radius:4px; padding:6px 8px;{% endif %}">
|
||||
<div style="font-size:0.9rem; font-weight:700">Speed</div>
|
||||
<div style="display:flex; align-items:center; gap:8px;">
|
||||
<div style="font-weight:800">{{ card.speed_display }}</div>
|
||||
<div aria-hidden="true" style="display:flex; gap:2px;">
|
||||
{% for t in card.speed_star_types_adj|default:card.speed_star_types %}
|
||||
<span style="font-size:0.95rem; line-height:1; color: {% if t == 'full' %}#1abc9c{% elif t == 'half' %}#1abc9c; opacity:0.55{% else %}#ddd{% endif %};">{% if t == 'empty' %}☆{% else %}★{% endif %}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; {% if card.is_top_trump and 'weight' in card.best_stats %}background:rgba(211,84,0,0.06); border-left:6px solid #d35400; border-radius:4px; padding:6px 8px;{% endif %}">
|
||||
<div style="font-size:0.9rem; font-weight:700">Weight</div>
|
||||
<div style="display:flex; align-items:center; gap:8px;">
|
||||
<div style="font-weight:800">{{ card.weight_display }}</div>
|
||||
<div aria-hidden="true" style="display:flex; gap:2px;">
|
||||
{% for t in card.weight_star_types_adj|default:card.weight_star_types %}
|
||||
<span style="font-size:0.95rem; line-height:1; color: {% if t == 'full' %}#d35400{% elif t == 'half' %}#d35400; opacity:0.55{% else %}#ddd{% endif %};">{% if t == 'empty' %}☆{% else %}★{% endif %}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; {% if card.is_top_trump and 'intelligence' in card.best_stats %}background:rgba(142,68,173,0.06); border-left:6px solid #8e44ad; border-radius:4px; padding:6px 8px;{% endif %}">
|
||||
<div style="font-size:0.9rem; font-weight:700">Intelligence</div>
|
||||
<div style="display:flex; align-items:center; gap:8px;">
|
||||
<div style="font-weight:800">{{ card.intelligence_display }}</div>
|
||||
<div aria-hidden="true" style="display:flex; gap:2px;">
|
||||
{% for t in card.intelligence_star_types_adj|default:card.intelligence_star_types %}
|
||||
<span style="font-size:0.95rem; line-height:1; color: {% if t == 'full' %}#8e44ad{% elif t == 'half' %}#8e44ad; opacity:0.55{% else %}#ddd{% endif %};">{% if t == 'empty' %}☆{% else %}★{% endif %}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; {% if card.is_top_trump and 'height' in card.best_stats %}background:rgba(39,174,96,0.06); border-left:6px solid #27ae60; border-radius:4px; padding:6px 8px;{% endif %}">
|
||||
<div style="font-size:0.9rem; font-weight:700">Height</div>
|
||||
<div style="display:flex; align-items:center; gap:8px;">
|
||||
<div style="font-weight:800">{{ card.height_display }}</div>
|
||||
<div aria-hidden="true" style="display:flex; gap:2px;">
|
||||
{% for t in card.height_star_types_adj|default:card.height_star_types %}
|
||||
<span style="font-size:0.95rem; line-height:1; color: {% if t == 'full' %}#27ae60{% elif t == 'half' %}#27ae60; opacity:0.55{% else %}#ddd{% endif %};">{% if t == 'empty' %}☆{% else %}★{% endif %}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endwith %}
|
||||
|
||||
{% if card.facts_list %}
|
||||
<div style="margin-top:6px; border-top:1px solid rgba(0,0,0,0.03); padding-top:6px; font-size:0.85rem; color:#333;">
|
||||
<strong style="display:block; margin-bottom:4px;">Fun facts</strong>
|
||||
<ul style="margin:0; padding-left:1rem; list-style:disc;">
|
||||
{% for fact in card.facts_list|slice:":2" %}
|
||||
<li>{{ fact }}</li>
|
||||
{% endfor %}
|
||||
{% if card.facts_list|length > 2 %}
|
||||
<li><em>and more...</em></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% comment %} <div style="flex:1; padding:14px; background:#fff;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; gap:8px;">
|
||||
<div style="font-weight:800; font-size:1.4rem">{{ card.name }}</div>
|
||||
</div>
|
||||
<div style="margin-top:8px">{{ card.description|linebreaksbr }}</div>
|
||||
{% if card.facts_list %}
|
||||
<div style="margin-top:8px">
|
||||
<strong>Fun facts</strong>
|
||||
<ul class="fact-list">
|
||||
{% for fact in card.facts_list %}
|
||||
<li>{{ fact }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div style="margin-top:12px; display:flex; flex-direction:column; gap:10px;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center;"><div>Speed</div><div style="font-weight:800">{{ card.speed_display }}</div></div>
|
||||
<div style="display:flex; justify-content:space-between; align-items:center;"><div>Weight</div><div style="font-weight:800">{{ card.weight_display }}</div></div>
|
||||
<div style="display:flex; justify-content:space-between; align-items:center;"><div>Intel</div><div style="font-weight:800">{{ card.intelligence_display }}</div></div>
|
||||
<div style="display:flex; justify-content:space-between; align-items:center;"><div>Height</div><div style="font-weight:800">{{ card.height_display }}</div></div>
|
||||
</div>
|
||||
</div> {% endcomment %}
|
||||
</div>
|
||||
@@ -0,0 +1,47 @@
|
||||
{# Shared inner fragment for Design 5 - core card markup only #}
|
||||
<div class="toptrump-card design-5" style="border-radius:6px; overflow:hidden; background:#fff; box-shadow:0 6px 20px rgba(0,0,0,0.08); position:relative;">
|
||||
{% if card.background and card.background.image or background and background.image %}
|
||||
<img class="print-hero-bg" src="{% if card.background and card.background.image %}{{ card.background.image.url }}{% else %}{{ background.image.url }}{% endif %}" alt="" aria-hidden="true" style="position:absolute; inset:0; width:100%; height:100%; object-fit:cover; z-index:0;">
|
||||
{% endif %}
|
||||
<div style="padding:14px; display:flex; align-items:center; gap:12px;">
|
||||
<div style="flex:1">
|
||||
<div style="font-weight:800; font-size:1.3rem">{{ card.name }}</div>
|
||||
<div class="is-size-7">{{ card.get_era_display }} — {{ card.get_diet_display }}</div>
|
||||
</div>
|
||||
{% if card.image %}
|
||||
<div style="width:120px; height:80px; display:flex; align-items:center; justify-content:center; background:#f0f0f0; border-radius:6px; overflow:hidden;">
|
||||
<img src="{{ card.image.url }}" alt="{{ card.name }}" style="max-height:76px; transform:scale({{ card.image_scale }});">
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="padding:10px 14px; border-top:1px solid #eee;">
|
||||
<div style="display:flex; gap:8px; justify-content:space-between;">
|
||||
<div style="flex:1; text-align:center;">
|
||||
<div style="font-weight:700">{{ card.speed_display }}</div>
|
||||
<div class="is-size-7">Speed</div>
|
||||
</div>
|
||||
<div style="flex:1; text-align:center;">
|
||||
<div style="font-weight:700">{{ card.weight_display }}</div>
|
||||
<div class="is-size-7">Weight</div>
|
||||
</div>
|
||||
<div style="flex:1; text-align:center;">
|
||||
<div style="font-weight:700">{{ card.intelligence_display }}</div>
|
||||
<div class="is-size-7">Intel</div>
|
||||
</div>
|
||||
<div style="flex:1; text-align:center;">
|
||||
<div style="font-weight:700">{{ card.height_display }}</div>
|
||||
<div class="is-size-7">Height</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if card.facts_list %}
|
||||
<div style="margin-top:10px;">
|
||||
<strong>Fun facts</strong>
|
||||
<ul class="fact-list">
|
||||
{% for fact in card.facts_list %}
|
||||
<li>{{ fact }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,13 +1,26 @@
|
||||
<article class="card-item" id="card-{{ card.pk }}">
|
||||
<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 %}'); background-size: {{ card.background_image_scale }}; background-position: center;">
|
||||
{% 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 }}" style="transform: translate(-50%,-50%) scale({{ card.image_scale }}); transform-origin: center; transition: transform 200ms ease;" />
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="title-band">{{ card.name }}</div>
|
||||
<div class="card-body">
|
||||
<div class="subtitle is-7"><strong>Era:</strong> {{ card.get_era_display }} — <strong>Diet:</strong> {{ card.get_diet_display }}</div>
|
||||
<div class="subtitle is-7">
|
||||
<strong>Era:</strong> {{ card.get_era_display }}
|
||||
—
|
||||
<strong>Diet:</strong>
|
||||
{% if card.diet == 'herbivore' %}
|
||||
<span class="tag is-light is-small" title="Herbivore"><span role="img" aria-label="Herbivore">🌿</span> Herbivore</span>
|
||||
{% elif card.diet == 'carnivore' %}
|
||||
<span class="tag is-light is-small" title="Carnivore"><span role="img" aria-label="Carnivore">🥩</span> Carnivore</span>
|
||||
{% elif card.diet == 'omnivore' %}
|
||||
<span class="tag is-light is-small" title="Omnivore"><span role="img" aria-label="Omnivore">🍽️</span> Omnivore</span>
|
||||
{% else %}
|
||||
{{ card.get_diet_display }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if card.facts_list %}
|
||||
<ul class="fact-list is-size-7">
|
||||
{% for fact in card.facts_list|slice:":2" %}
|
||||
@@ -16,14 +29,41 @@
|
||||
</ul>
|
||||
{% endif %}
|
||||
<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-label">Weight</div><div class="stat-bar"><div class="stat-fill" style="width:{{ card.weight }}%"></div></div></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-row">
|
||||
<div class="stat-label">Speed <small class="is-size-7">(km/h)</small></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; display:flex; align-items:center; gap:8px;">
|
||||
<div class="stars">{% for t in card.speed_star_types_adj|default:card.speed_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}</div>
|
||||
<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-value" style="margin-left:8px;font-weight:600">
|
||||
<div>{{ card.weight_display }}</div>
|
||||
<div class="is-size-7" style="font-weight:500; display:flex; align-items:center; gap:8px;">
|
||||
<div class="stars">{% for t in card.weight_star_types_adj|default:card.weight_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}</div>
|
||||
<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-value" style="margin-left:8px;font-weight:600">
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<div class="is-size-7" style="font-weight:600"><div class="stars">{% for t in card.intelligence_star_types_adj|default:card.intelligence_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}</div></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">
|
||||
<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-primary" hx-get="{% url 'cards:preview' card.pk %}" hx-target="#modal-root" hx-swap="innerHTML">Preview</button>
|
||||
<button class="button is-small is-danger" hx-post="{% url 'cards:delete' card.pk %}" hx-confirm="Delete this card?" hx-swap="none">Delete</button>
|
||||
<button class="button is-small is-info" hx-get="{% url 'cards:edit' card.pk %}" hx-target="#modal-root" hx-swap="innerHTML">Edit</button>
|
||||
<button class="button is-small is-primary" hx-get="{% url 'cards:preview' card.pk %}" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true">Preview</button>
|
||||
|
||||
<button class="button is-small is-danger" hx-post="{% url 'cards:delete' card.pk %}" hx-confirm="Delete this card?" hx-target="#card-{{ card.pk }}" hx-swap="outerHTML">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
{# HTMX modal fragment for previewing a card #}
|
||||
{# HTMX modal fragment for previewing a card in Top Trumps style #}
|
||||
<div class="modal is-active" id="card-preview-modal">
|
||||
<div class="modal-background" onclick="document.getElementById('modal-root').innerHTML=''" ></div>
|
||||
<div class="modal-background" onclick="closeCardPreview()" ></div>
|
||||
<div class="modal-content">
|
||||
<div style="max-width:920px; margin:0 auto;">
|
||||
<div style="display:flex; justify-content:center;">
|
||||
<div class="toptrump-card">
|
||||
{# 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 %}
|
||||
<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 %}
|
||||
</div>
|
||||
<div class="title-band">{{ card.name }}</div>
|
||||
<div class="card-body">
|
||||
<div class="subtitle is-6"><strong>Era:</strong> {{ card.get_era_display }} — <strong>Diet:</strong> {{ card.get_diet_display }}</div>
|
||||
<div class="subtitle is-6"><strong>Era:</strong> {{ card.get_era_display }} — <strong>Diet:</strong>
|
||||
{% if card.diet == 'herbivore' %}
|
||||
<span style="display:inline-block;padding:4px 8px;border-radius:999px;background:#2ecc71;color:#fff;font-weight:700;margin-left:6px;">🥦 Herbivore</span>
|
||||
{% elif card.diet == 'carnivore' %}
|
||||
<span style="display:inline-block;padding:4px 8px;border-radius:999px;background:#e74c3c;color:#fff;font-weight:700;margin-left:6px;">🥩 Carnivore</span>
|
||||
{% else %}
|
||||
<span style="display:inline-block;padding:4px 8px;border-radius:999px;background:#f39c12;color:#fff;font-weight:700;margin-left:6px;">🍽️ Omnivore</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="margin-top:0.5rem">{{ card.description|linebreaksbr }}</div>
|
||||
|
||||
{% if card.facts_list %}
|
||||
@@ -29,15 +37,39 @@
|
||||
{% endif %}
|
||||
|
||||
<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-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-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-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-row">
|
||||
<div class="stat-label">Speed <small class="is-size-7">(km/h)</small></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; display:flex; align-items:center; gap:8px;"><div class="stars">{% for t in card.speed_star_types_adj|default:card.speed_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}</div> <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-value" style="margin-left:8px;font-weight:700">
|
||||
<div>{{ card.weight_display }}</div>
|
||||
<div class="is-size-7" style="font-weight:500; display:flex; align-items:center; gap:8px;"><div class="stars">{% for t in card.weight_star_types_adj|default:card.weight_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}</div> <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-value" style="margin-left:8px;font-weight:700">
|
||||
<div style="display:flex;align-items:center;gap:8px"><div class="is-size-7" style="font-weight:600"><div class="stars">{% for t in card.intelligence_star_types_adj|default:card.intelligence_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}</div></div>
|
||||
<div class="is-size-7" style="font-weight:500">(orig: {{ card.intelligence_orig }})</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<div class="stat-label">Height <small class="is-size-7">(m)</small></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; display:flex; align-items:center; gap:8px;"><div class="stars">{% for t in card.height_star_types_adj|default:card.height_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}</div> <span style="margin-left:6px">(orig: {{ card.height_orig }})</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="modal-close is-large" aria-label="close" onclick="document.getElementById('modal-root').innerHTML=''"></button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="modal-close is-large" aria-label="close" onclick="closeCardPreview()"></button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
{# Design 1: Classic Top Trumps - clean, balanced layout #}
|
||||
<div class="modal is-active" id="card-preview-modal">
|
||||
<div class="modal-background" onclick="closeCardPreview()"></div>
|
||||
<div class="modal-content">
|
||||
<div style="max-width:920px; margin:0 auto;">
|
||||
<div style="display:flex; justify-content:center;">
|
||||
{% include 'cards/_card_inner_design1.html' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="position:absolute; top:12px; right:12px; display:flex; gap:8px;">
|
||||
{% if prev_pk %}
|
||||
<a class="button is-medium is-info" hx-get="{% url 'cards:preview' prev_pk %}?design={{ design }}" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true" aria-label="Previous">← Prev</a>
|
||||
{% else %}
|
||||
<button class="button is-medium is-static" disabled aria-hidden="true">← Prev</button>
|
||||
{% endif %}
|
||||
{% if next_pk %}
|
||||
<a class="button is-medium is-info" hx-get="{% url 'cards:preview' next_pk %}?design={{ design }}" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true" aria-label="Next">Next →</a>
|
||||
{% else %}
|
||||
<button class="button is-medium is-static" disabled aria-hidden="true">Next →</button>
|
||||
{% endif %}
|
||||
<a class="button is-medium {% if design == 1 %}is-primary{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=1" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true" aria-label="Design 1">1</a>
|
||||
<a class="button is-medium {% if design == 2 %}is-primary{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=2" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true" aria-label="Design 2">2</a>
|
||||
<a class="button is-medium {% if design == 3 %}is-primary{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=3" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true" aria-label="Design 3">3</a>
|
||||
<a class="button is-medium {% if design == 4 %}is-primary{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=4" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true" aria-label="Design 4">4</a>
|
||||
<a class="button is-medium {% if design == 5 %}is-primary{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=5" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true" aria-label="Design 5">5</a>
|
||||
</div>
|
||||
<button class="modal-close is-large" aria-label="close" onclick="closeCardPreview()" style="right:8px;"></button>
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
{# Design 2: Dark, bold band and inverted text #}
|
||||
<div class="modal is-active" id="card-preview-modal">
|
||||
<div class="modal-background" onclick="closeCardPreview()"></div>
|
||||
<div class="modal-content">
|
||||
<div style="max-width:920px; margin:0 auto;">
|
||||
<div style="display:flex; justify-content:center;">
|
||||
{% include 'cards/_card_inner_design2.html' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="position:absolute; top:12px; right:12px; display:flex; gap:8px;">
|
||||
{% if prev_pk %}
|
||||
<a class="button is-medium is-info" hx-get="{% url 'cards:preview' prev_pk %}?design={{ design }}" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true" aria-label="Previous">← Prev</a>
|
||||
{% else %}
|
||||
<button class="button is-medium is-static" disabled aria-hidden="true">← Prev</button>
|
||||
{% endif %}
|
||||
{% if next_pk %}
|
||||
<a class="button is-medium is-info" hx-get="{% url 'cards:preview' next_pk %}?design={{ design }}" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true" aria-label="Next">Next →</a>
|
||||
{% else %}
|
||||
<button class="button is-medium is-static" disabled aria-hidden="true">Next →</button>
|
||||
{% endif %}
|
||||
<a class="button is-medium {% if design == 1 %}is-primary{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=1" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true" aria-label="Design 1">1</a>
|
||||
<a class="button is-medium {% if design == 2 %}is-primary is-link{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=2" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true" aria-label="Design 2">2</a>
|
||||
<a class="button is-medium {% if design == 3 %}is-primary{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=3" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true" aria-label="Design 3">3</a>
|
||||
<a class="button is-medium {% if design == 4 %}is-primary{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=4" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true" aria-label="Design 4">4</a>
|
||||
<a class="button is-medium {% if design == 5 %}is-primary{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=5" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true" aria-label="Design 5">5</a>
|
||||
</div>
|
||||
<button class="modal-close is-large" aria-label="close" onclick="closeCardPreview()" style="right:8px;"></button>
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
{# Design 3: Bright gradient header and centered stats badges #}
|
||||
<div class="modal is-active" id="card-preview-modal">
|
||||
<div class="modal-background" onclick="closeCardPreview()"></div>
|
||||
<div class="modal-content">
|
||||
<div style="max-width:920px; margin:0 auto;">
|
||||
<div style="display:flex; justify-content:center;">
|
||||
{% include 'cards/_card_inner_design3.html' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="position:absolute; top:12px; right:12px; display:flex; gap:8px;">
|
||||
{% if prev_pk %}
|
||||
<a class="button is-medium is-info" hx-get="{% url 'cards:preview' prev_pk %}?design={{ design }}" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true" aria-label="Previous">← Prev</a>
|
||||
{% else %}
|
||||
<button class="button is-medium is-static" disabled aria-hidden="true">← Prev</button>
|
||||
{% endif %}
|
||||
{% if next_pk %}
|
||||
<a class="button is-medium is-info" hx-get="{% url 'cards:preview' next_pk %}?design={{ design }}" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true" aria-label="Next">Next →</a>
|
||||
{% else %}
|
||||
<button class="button is-medium is-static" disabled aria-hidden="true">Next →</button>
|
||||
{% endif %}
|
||||
<a class="button is-medium {% if design == 1 %}is-primary{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=1" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true" aria-label="Design 1">1</a>
|
||||
<a class="button is-medium {% if design == 2 %}is-primary{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=2" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true" aria-label="Design 2">2</a>
|
||||
<a class="button is-medium {% if design == 3 %}is-primary is-link{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=3" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true" aria-label="Design 3">3</a>
|
||||
<a class="button is-medium {% if design == 4 %}is-primary{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=4" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true" aria-label="Design 4">4</a>
|
||||
<a class="button is-medium {% if design == 5 %}is-primary{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=5" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true" aria-label="Design 5">5</a>
|
||||
</div>
|
||||
<button class="modal-close is-large" aria-label="close" onclick="closeCardPreview()" style="right:8px;"></button>
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
{# Design 4: Sidebar stats and portrait orientation #}
|
||||
<div class="modal is-active" id="card-preview-modal">
|
||||
<div class="modal-background" onclick="closeCardPreview()"></div>
|
||||
<div class="modal-content">
|
||||
<div style="max-width:920px; margin:0 auto;">
|
||||
<div style="display:flex; justify-content:center;">
|
||||
{% include 'cards/_card_inner_design4.html' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="position:absolute; top:12px; right:12px; display:flex; gap:8px;">
|
||||
{% if prev_pk %}
|
||||
<a class="button is-medium is-info" hx-get="{% url 'cards:preview' prev_pk %}?design={{ design }}" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true" aria-label="Previous">← Prev</a>
|
||||
{% else %}
|
||||
<button class="button is-medium is-static" disabled aria-hidden="true">← Prev</button>
|
||||
{% endif %}
|
||||
{% if next_pk %}
|
||||
<a class="button is-medium is-info" hx-get="{% url 'cards:preview' next_pk %}?design={{ design }}" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true" aria-label="Next">Next →</a>
|
||||
{% else %}
|
||||
<button class="button is-medium is-static" disabled aria-hidden="true">Next →</button>
|
||||
{% endif %}
|
||||
<a class="button is-medium {% if design == 1 %}is-primary{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=1" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true" aria-label="Design 1">1</a>
|
||||
<a class="button is-medium {% if design == 2 %}is-primary{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=2" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true" aria-label="Design 2">2</a>
|
||||
<a class="button is-medium {% if design == 3 %}is-primary{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=3" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true" aria-label="Design 3">3</a>
|
||||
<a class="button is-medium {% if design == 4 %}is-primary is-link{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=4" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true" aria-label="Design 4">4</a>
|
||||
<a class="button is-medium {% if design == 5 %}is-primary{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=5" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true" aria-label="Design 5">5</a>
|
||||
</div>
|
||||
<button class="modal-close is-large" aria-label="close" onclick="closeCardPreview()" style="right:8px;"></button>
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
{# Design 5: Minimal, high-contrast with icon-like stat rows #}
|
||||
<div class="modal is-active" id="card-preview-modal">
|
||||
<div class="modal-background" onclick="closeCardPreview()"></div>
|
||||
<div class="modal-content">
|
||||
<div style="max-width:920px; margin:0 auto;">
|
||||
<div style="display:flex; justify-content:center;">
|
||||
{% include 'cards/_card_inner_design5.html' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="position:absolute; top:12px; right:12px; display:flex; gap:8px;">
|
||||
{% if prev_pk %}
|
||||
<a class="button is-medium is-info" hx-get="{% url 'cards:preview' prev_pk %}?design={{ design }}" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true" aria-label="Previous">← Prev</a>
|
||||
{% else %}
|
||||
<button class="button is-medium is-static" disabled aria-hidden="true">← Prev</button>
|
||||
{% endif %}
|
||||
{% if next_pk %}
|
||||
<a class="button is-medium is-info" hx-get="{% url 'cards:preview' next_pk %}?design={{ design }}" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true" aria-label="Next">Next →</a>
|
||||
{% else %}
|
||||
<button class="button is-medium is-static" disabled aria-hidden="true">Next →</button>
|
||||
{% endif %}
|
||||
<a class="button is-medium {% if design == 1 %}is-primary{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=1" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true" aria-label="Design 1">1</a>
|
||||
<a class="button is-medium {% if design == 2 %}is-primary{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=2" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true" aria-label="Design 2">2</a>
|
||||
<a class="button is-medium {% if design == 3 %}is-primary{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=3" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true" aria-label="Design 3">3</a>
|
||||
<a class="button is-medium {% if design == 4 %}is-primary{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=4" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true" aria-label="Design 4">4</a>
|
||||
<a class="button is-medium {% if design == 5 %}is-primary is-link{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=5" hx-target="#modal-root" hx-swap="innerHTML" hx-push-url="true" aria-label="Design 5">5</a>
|
||||
</div>
|
||||
<button class="modal-close is-large" aria-label="close" onclick="closeCardPreview()" style="right:8px;"></button>
|
||||
</div>
|
||||
@@ -0,0 +1,30 @@
|
||||
<div class="stat-row" style="display:flex; gap:12px; flex-direction:column">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center">
|
||||
<div class="stat-label">Speed <small class="is-size-7">(km/h)</small></div>
|
||||
<div style="text-align:right">
|
||||
<div style="font-weight:700">{{ card.speed_display }}</div>
|
||||
<div class="is-size-7" style="font-weight:500; display:flex; align-items:center; gap:8px; justify-content:flex-end;"><div class="stars">{% for t in card.speed_star_types_adj|default:card.speed_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}</div> <span style="margin-left:6px">(orig: {{ card.speed_orig }})</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; justify-content:space-between; align-items:center">
|
||||
<div class="stat-label">Weight <small class="is-size-7">(kg)</small></div>
|
||||
<div style="text-align:right">
|
||||
<div style="font-weight:700">{{ card.weight_display }}</div>
|
||||
<div class="is-size-7" style="font-weight:500; display:flex; align-items:center; gap:8px; justify-content:flex-end;"><div class="stars">{% for t in card.weight_star_types_adj|default:card.weight_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}</div> <span style="margin-left:6px">(orig: {{ card.weight_orig }})</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; justify-content:space-between; align-items:center">
|
||||
<div class="stat-label">Intelligence <small class="is-size-7">(score)</small></div>
|
||||
<div style="text-align:right">
|
||||
<div style="font-weight:700">{{ card.intelligence_display }}</div>
|
||||
<div class="is-size-7" style="font-weight:500; display:flex; align-items:center; gap:8px; justify-content:flex-end;"><div class="stars">{% for t in card.intelligence_star_types_adj|default:card.intelligence_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}</div> <span style="margin-left:6px">(orig: {{ card.intelligence_orig }})</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; justify-content:space-between; align-items:center">
|
||||
<div class="stat-label">Height <small class="is-size-7">(m)</small></div>
|
||||
<div style="text-align:right">
|
||||
<div style="font-weight:700">{{ card.height_display }}</div>
|
||||
<div class="is-size-7" style="font-weight:500; display:flex; align-items:center; gap:8px; justify-content:flex-end;"><div class="stars">{% for t in card.height_star_types_adj|default:card.height_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}</div> <span style="margin-left:6px">(orig: {{ card.height_orig }})</span></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">
|
||||
{% 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 #}
|
||||
<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 %}
|
||||
{% csrf_token %}
|
||||
{{ form.non_field_errors }}
|
||||
@@ -61,6 +61,44 @@
|
||||
</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>
|
||||
(function(){
|
||||
// Attach to file inputs within this form fragment and update the .file-name text
|
||||
@@ -76,6 +114,18 @@
|
||||
if(!label) return;
|
||||
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;
|
||||
// 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
|
||||
const outerLabel = fi.closest('.file').querySelector('.file-label');
|
||||
@@ -93,19 +143,47 @@
|
||||
<div class="field-body">
|
||||
<div class="field">
|
||||
<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 class="field">
|
||||
<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 class="field">
|
||||
<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 class="field">
|
||||
<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>
|
||||
@@ -115,7 +193,7 @@
|
||||
<button class="button is-primary" type="submit">Save</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button class="button is-light" type="button" onclick="document.getElementById('htmx-form').innerHTML=''">Cancel</button>
|
||||
<button class="button is-light" type="button" onclick="(function(){ const m=document.getElementById('modal-root'); if(m) m.innerHTML=''; const h=document.getElementById('htmx-form'); if(h) h.innerHTML=''; })()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{# HTMX modal wrapper for the create/edit form #}
|
||||
<div class="modal is-active" id="card-modal">
|
||||
<div class="modal-background" onclick="document.getElementById('modal-root').innerHTML=''" ></div>
|
||||
<div class="modal-content">
|
||||
<div style="max-width:920px; margin:0 auto;">
|
||||
{% include 'cards/_form.html' %}
|
||||
</div>
|
||||
</div>
|
||||
<button class="modal-close is-large" aria-label="close" onclick="document.getElementById('modal-root').innerHTML=''"></button>
|
||||
</div>
|
||||
@@ -0,0 +1,49 @@
|
||||
{% comment %} HTMX fragment showing LLM suggestion and form to accept/apply it. {% endcomment %}
|
||||
<div id="import-preview" class="box">
|
||||
{% if error %}
|
||||
<div class="notification is-danger">Error contacting LLM: {{ error }}</div>
|
||||
{% else %}
|
||||
<h4 class="title is-6">Import suggestions for {{ card.name }}</h4>
|
||||
<form hx-post="{% url 'cards:import_apply' card.pk %}" hx-target="#htmx-form" hx-swap="innerHTML">
|
||||
<table class="table is-fullwidth">
|
||||
<tr><th>Field</th><th>Suggested</th></tr>
|
||||
<tr>
|
||||
<td>Speed (km/h)</td>
|
||||
<td><input name="speed_kmh" value="{{ suggestion.speed_kmh|default_if_none:'' }}" class="input" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Weight (kg)</td>
|
||||
<td><input name="weight_kg" value="{{ suggestion.weight_kg|default_if_none:'' }}" class="input" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Height (m)</td>
|
||||
<td><input name="height_m" value="{{ suggestion.height_m|default_if_none:'' }}" class="input" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Intelligence (1-5 or 0-100)</td>
|
||||
<td><input name="intelligence_score" value="{{ suggestion.intelligence_score|default_if_none:'' }}" class="input" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Facts</td>
|
||||
<td>
|
||||
<textarea name="facts" class="textarea" rows="6">{% if suggestion.facts %}{{ suggestion.facts|join:"\n" }}{% endif %}</textarea>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% if suggestion.sources %}
|
||||
<div class="content">
|
||||
<strong>Sources:</strong>
|
||||
<ul>
|
||||
{% for s in suggestion.sources %}
|
||||
<li>{% if s.url %}<a href="{{ s.url }}" target="_blank">{{ s.title|default:s.url }}</a>{% else %}{{ s.title }}{% endif %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div style="margin-top:0.5rem">
|
||||
<button class="button is-primary" type="submit">Apply</button>
|
||||
<button class="button" type="button" onclick="document.getElementById('htmx-form').innerHTML=''">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -11,6 +11,9 @@
|
||||
<div class="level-item">
|
||||
<a class="button is-light" href="{% url 'cards:list' %}">Back to cards</a>
|
||||
</div>
|
||||
<div class="level-item">
|
||||
<a class="button is-link" href="{% url 'cards:backgrounds_download_all' %}">Download all backgrounds</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<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">
|
||||
<script src="https://unpkg.com/htmx.org@1.9.5"></script>
|
||||
<style>
|
||||
@@ -33,7 +35,77 @@
|
||||
#theme-toggle{margin-left:0.5rem}
|
||||
/* 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 .hero-image{height:180px;background-size:cover;background-position:center}
|
||||
/* Design-specific fixed sizing so previews remain consistent */
|
||||
.toptrump-card.design-1,
|
||||
.toptrump-card.design-2,
|
||||
.toptrump-card.design-3,
|
||||
.toptrump-card.design-5 {
|
||||
width: 520px;
|
||||
min-height: 560px;
|
||||
}
|
||||
.toptrump-card.design-4 {
|
||||
width: 680px;
|
||||
min-height: 620px;
|
||||
display: flex;
|
||||
}
|
||||
/* Slightly reduced hero image height for design-4 so previews fit better */
|
||||
.toptrump-card.design-4 .hero-image { height: 440px; }
|
||||
/* For design-4 use a non-cropping image layout so wide dinos aren't clipped */
|
||||
.toptrump-card.design-4 .hero-image{ overflow:hidden; display:flex; align-items:center; justify-content:center }
|
||||
.toptrump-card.design-4 .card-hero-img{
|
||||
position:static !important;
|
||||
left:auto !important;
|
||||
top:auto !important;
|
||||
transform:none !important;
|
||||
display:block;
|
||||
margin:0 auto;
|
||||
/* Allow a modest overflow so the image is cropped by the hero's
|
||||
`overflow:hidden` — this intentionally crops ~20% horizontally */
|
||||
width:120% !important;
|
||||
max-width:none !important;
|
||||
max-height:120% !important;
|
||||
object-position:center center !important;
|
||||
height:auto;
|
||||
object-fit:contain;
|
||||
}
|
||||
.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
|
||||
Exclude design-4 here so that design-4 can use `object-fit: contain` and
|
||||
avoid horizontal cropping (design-4 has its own layout rules above). */
|
||||
.toptrump-card:not(.design-4) .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;
|
||||
}
|
||||
/* Consistent star group styling */
|
||||
.toptrump-card .stars{display:inline-flex; gap:6px; align-items:center; font-size:1rem; margin-left:6px}
|
||||
.toptrump-card .stars .star{margin:0;}
|
||||
/* 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 .card-body{padding:0.75rem}
|
||||
.toptrump-card .stat-row{display:flex;align-items:center;gap:0.75rem;padding:0.25rem 0}
|
||||
@@ -41,7 +113,18 @@
|
||||
.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 .fact-list{margin-top:0.5rem;padding-left:1rem}
|
||||
/* Modal form styling for create/edit */
|
||||
.modal.is-active .modal-background{background-color:rgba(10,10,10,0.6)}
|
||||
.modal .modal-content > div{display:flex; justify-content:center}
|
||||
.modal .card-form.card{max-width:820px; width:100%; padding:1.25rem; border-radius:10px; box-shadow:0 18px 50px rgba(2,6,23,0.35); border:1px solid rgba(0,0,0,0.08); background:var(--card-bg)}
|
||||
.modal .card-form .field{margin-bottom:0.75rem}
|
||||
.modal .card-form .label{font-weight:700}
|
||||
.modal .card-form .file-label .file-cta{background:rgba(0,0,0,0.03); border-radius:6px}
|
||||
.modal .card-form .buttons{justify-content:flex-end}
|
||||
.modal .card-form .button.is-primary{min-width:110px}
|
||||
.modal .card-form .background-picker .bg-option{width:56px;height:40px}
|
||||
</style>
|
||||
{% include 'cards/_card_hero_styles.html' %}
|
||||
</head>
|
||||
<body>
|
||||
<section class="section">
|
||||
@@ -58,7 +141,7 @@
|
||||
</div>
|
||||
<div id="messages"></div>
|
||||
{% block content %}{% endblock %}
|
||||
<div id="modal-root"></div>
|
||||
<div id="modal-root">{% if modal_fragment %}{{ modal_fragment|safe }}{% endif %}</div>
|
||||
</div>
|
||||
</section>
|
||||
</body>
|
||||
@@ -85,4 +168,112 @@
|
||||
}
|
||||
})();
|
||||
</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>
|
||||
<script>
|
||||
// Auto-open preview modal when visiting a preview URL directly
|
||||
(function(){
|
||||
function tryOpenPreviewFromUrl(){
|
||||
try{
|
||||
var path = window.location.pathname;
|
||||
// Match paths like /cards/preview/123/ optionally with trailing slash
|
||||
var m = path.match(/\/cards\/preview\/(\d+)\/?$/);
|
||||
if(m){
|
||||
var root = document.getElementById('modal-root');
|
||||
// If server already rendered a modal fragment into #modal-root, do nothing.
|
||||
if(root && root.innerHTML && root.innerHTML.trim().length > 0) return;
|
||||
var url = window.location.pathname + window.location.search;
|
||||
fetch(url, {credentials: 'same-origin'})
|
||||
.then(function(r){ if(!r.ok) throw new Error('fetch failed'); return r.text(); })
|
||||
.then(function(html){
|
||||
var root = document.getElementById('modal-root');
|
||||
if(root){ root.innerHTML = html; }
|
||||
}).catch(function(){ /* ignore */ });
|
||||
}
|
||||
}catch(e){ /* ignore */ }
|
||||
}
|
||||
if(document.readyState === 'loading') document.addEventListener('DOMContentLoaded', tryOpenPreviewFromUrl);
|
||||
else tryOpenPreviewFromUrl();
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// HTMX-aware preview close handler: capture previous URL when a push happens
|
||||
(function(){
|
||||
// record the URL before HTMX does a push so we can restore it when closing the modal
|
||||
document.body.addEventListener('htmx:beforeRequest', function(evt){
|
||||
try{
|
||||
var trigger = evt.detail.elt;
|
||||
if(trigger && trigger.getAttribute && trigger.getAttribute('hx-push-url') != null){
|
||||
// Save the current location so we can restore when closing the preview modal
|
||||
window.__htmx_prev_url = window.location.href;
|
||||
}
|
||||
}catch(e){ /* ignore */ }
|
||||
});
|
||||
|
||||
// Close the preview modal and restore the previous URL if we captured one.
|
||||
window.closeCardPreview = function(){
|
||||
try{
|
||||
var root = document.getElementById('modal-root');
|
||||
if(root) root.innerHTML = '';
|
||||
if(window.__htmx_prev_url){
|
||||
history.pushState(null, '', window.__htmx_prev_url);
|
||||
try{ delete window.__htmx_prev_url; }catch(e){}
|
||||
return;
|
||||
}
|
||||
// Fallback: if URL looks like a preview URL, remove it with replaceState
|
||||
try{
|
||||
var u = new URL(window.location.href);
|
||||
if(u.pathname.match(/\/cards\/preview\/\d+\/?/) || u.searchParams.has('design')){
|
||||
// remove query and path by replacing with root (keep origin)
|
||||
history.replaceState(null, '', '/');
|
||||
}
|
||||
}catch(e){}
|
||||
}catch(e){ /* ignore */ }
|
||||
};
|
||||
|
||||
// Close on Escape key too
|
||||
document.addEventListener('keydown', function(e){
|
||||
if(e.key === 'Escape'){
|
||||
var root = document.getElementById('modal-root');
|
||||
if(root && root.innerHTML && root.innerHTML.trim().length>0){
|
||||
window.closeCardPreview();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// When HTMX swaps in to #modal-root, focus the first actionable control
|
||||
document.body.addEventListener('htmx:afterSwap', function(evt){
|
||||
try{
|
||||
if(evt.detail.target && evt.detail.target.id === 'modal-root'){
|
||||
var root = document.getElementById('modal-root');
|
||||
var focusable = root.querySelector('button,a,input,select,textarea,[tabindex]:not([tabindex="-1"])');
|
||||
if(focusable) focusable.focus();
|
||||
}
|
||||
}catch(e){}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
{% extends "cards/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="section">
|
||||
<h2 class="title is-4">Cards overview — bulk edit{% if card_count %} ({{ card_count }} card{{ card_count|pluralize }}){% endif %}</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 %}
|
||||
@@ -0,0 +1,46 @@
|
||||
{% extends 'cards/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<section class="section">
|
||||
<div class="level">
|
||||
<div class="level-left">
|
||||
<h2 class="title is-4">Export table of all dinosaurs</h2>
|
||||
</div>
|
||||
<div class="level-right">
|
||||
<div class="level-item">
|
||||
<a class="button is-light" href="{% url 'cards:list' %}">Back to list</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p>Copy the JSON below and paste it into your web LLM interface. Ask the model to return a JSON array of objects with these fields: <code>pk</code> or <code>name</code>, <code>era</code> (<em>code</em>) and <code>era_display</code> (human label), <code>diet</code> and <code>diet_display</code>, <code>speed_kmh</code>, <code>weight_kg</code>, <code>height_m</code>, <code>intelligence</code>, <code>facts</code> (array or newline-separated string). When you get the model response, paste that JSON into the box below and click <strong>Preview pasted results</strong>.</p>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Export (JSON)</label>
|
||||
<div class="control">
|
||||
<textarea id="export-table-text" class="textarea" rows="10">{{ json_text }}</textarea>
|
||||
</div>
|
||||
<div style="margin-top:0.5rem">
|
||||
<button class="button" onclick="(function(){var t=document.getElementById('export-table-text');t.select();try{navigator.clipboard.writeText(t.value);}catch(e){} })()">Copy JSON</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<h3 class="title is-6">Paste LLM output</h3>
|
||||
<form method="post" hx-post="{% url 'cards:import_paste' %}" hx-target="#import-preview" hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<textarea name="pasted" class="textarea" rows="12" placeholder='Paste the LLM response here (JSON array, NDJSON, or CSV/TSV)'></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="submit">Preview pasted results</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="import-preview"></div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -4,16 +4,82 @@
|
||||
<div class="level">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
<button class="button is-primary" hx-get="{% url 'cards:create' %}" hx-target="#htmx-form" hx-swap="innerHTML">Add Dinosaur</button>
|
||||
<button class="button is-primary" hx-get="{% url 'cards:create' %}" hx-target="#modal-root" hx-swap="innerHTML">Add Dinosaur</button>
|
||||
</div>
|
||||
<div class="level-item">
|
||||
<a class="button is-light" href="{% url 'cards:backgrounds' %}">Manage backgrounds</a>
|
||||
</div>
|
||||
<div class="level-item">
|
||||
<a class="button is-light" href="{% url 'cards:overview' %}">Bulk edit</a>
|
||||
</div>
|
||||
<div class="level-item">
|
||||
<a class="button is-light" href="{% url 'cards:export_table' %}">Export table</a>
|
||||
</div>
|
||||
<div class="level-item">
|
||||
<a class="button is-light" href="{% url 'cards:print' %}">Print cards</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if card_count is not None %}
|
||||
<div class="level-item">
|
||||
<span class="tag is-light">{{ card_count }} card{% if card_count > 1 %}s{% endif %}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<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">
|
||||
{% include 'cards/_cards_list.html' %}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
{% include 'cards/_card_preview.html' %}
|
||||
{% include fragment_template %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
{% extends 'cards/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div style="display:flex; align-items:center; gap:12px; margin-bottom:12px;">
|
||||
<a class="button is-light" href="{% url 'cards:list' %}">← Back to list</a>
|
||||
<h2 class="title" style="margin:0">Print cards</h2>
|
||||
</div>
|
||||
<p class="subtitle">Select which cards to include and choose a design. Click <strong>Open printable view</strong> to open the print preview in a new tab (you can then print to PDF or to an A4 printer).</p>
|
||||
|
||||
<form id="print-form" method="post" action="{% url 'cards:print_preview' %}" target="_blank">
|
||||
{% csrf_token %}
|
||||
<div style="display:flex; gap:12px; align-items:flex-start; margin-bottom:10px;">
|
||||
<div>
|
||||
<label class="label">Design</label>
|
||||
<div class="select">
|
||||
<select name="design">
|
||||
<option value="1">Design 1</option>
|
||||
<option value="2">Design 2</option>
|
||||
<option value="3">Design 3</option>
|
||||
<option value="4">Design 4</option>
|
||||
<option value="5">Design 5</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Scale</label>
|
||||
<div class="control"><input class="input" type="number" name="scale" value="1" step="0.05" min="0.5" max="2"></div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Cards per page</label>
|
||||
<div class="select">
|
||||
<select name="per_page">
|
||||
<option value="1">1</option>
|
||||
<option value="2" selected>2</option>
|
||||
<option value="4">4</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; align-items:center; gap:8px;">
|
||||
<label style="display:flex; align-items:center; gap:6px; margin-top:18px;">
|
||||
<input id="select-all-cards" type="checkbox">
|
||||
<span class="is-size-7">Select all / none</span>
|
||||
</label>
|
||||
</div>
|
||||
<div style="margin-left:auto">
|
||||
<button class="button is-primary" type="submit">Open printable view</button>
|
||||
</div>
|
||||
<!-- Sample preview placeholder (moved out of the form to avoid layout issues) -->
|
||||
</div>
|
||||
|
||||
<div style="display:flex; flex-direction:column; gap:8px;">
|
||||
{% for card in cards %}
|
||||
<label style="display:flex; align-items:center; gap:10px; padding:6px; border:1px solid rgba(0,0,0,0.04); border-radius:6px;">
|
||||
<input type="checkbox" name="cards" value="{{ card.pk }}" checked>
|
||||
<div style="display:flex; align-items:center; gap:10px;">
|
||||
{% if card.image %}
|
||||
<img src="{{ card.image.url }}" style="width:72px; height:56px; object-fit:cover; border-radius:6px;">
|
||||
{% else %}
|
||||
<div style="width:72px; height:56px; background:#f2f2f2; border-radius:6px"></div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<div style="font-weight:700">{{ card.name }}</div>
|
||||
<div class="is-size-7">{{ card.get_era_display }} — {{ card.get_diet_display }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</form>
|
||||
{% if cards %}
|
||||
{% with sample_card=cards.0 %}
|
||||
<div id="sample-panel" style="position:fixed; right:16px; bottom:16px; width:720px; height:640px; z-index:1400; display:block;">
|
||||
<div id="sample-panel-inner" style="border:1px solid rgba(0,0,0,0.06); padding:10px; border-radius:10px; background:#fff; box-shadow:0 10px 30px rgba(0,0,0,0.12); width:100%; height:100%; box-sizing:border-box;">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:8px;">
|
||||
<div style="font-weight:700">Sample</div>
|
||||
<button id="sample-hide-btn" type="button" style="border:0; background:transparent; cursor:pointer; font-weight:700">✕</button>
|
||||
</div>
|
||||
<div id="sample-preview" class="card-canonical" style="width:100%; height:calc(100% - 56px); overflow:auto; position:relative; display:flex; align-items:flex-start; justify-content:center; padding:8px; box-sizing:border-box;">
|
||||
{% comment %} Render all design fragments hidden and toggle via JS. Each fragment should contain a `.toptrump-card`. {% endcomment %}
|
||||
<div class="design-preview design-1" style="display:none">
|
||||
{% include 'cards/_card_inner_design1.html' with card=sample_card background=background %}
|
||||
</div>
|
||||
<div class="design-preview design-2" style="display:none">
|
||||
{% include 'cards/_card_inner_design2.html' with card=sample_card background=background %}
|
||||
</div>
|
||||
<div class="design-preview design-3" style="display:none">
|
||||
{% include 'cards/_card_inner_design3.html' with card=sample_card background=background %}
|
||||
</div>
|
||||
<div class="design-preview design-4" style="display:none">
|
||||
{% include 'cards/_card_inner_design4.html' with card=sample_card background=background %}
|
||||
</div>
|
||||
<div class="design-preview design-5" style="display:none">
|
||||
{% include 'cards/_card_inner_design5.html' with card=sample_card background=background %}
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:8px; font-size:12px; color:#666">Design preview — not printed</div>
|
||||
</div>
|
||||
<button id="sample-show-tab" type="button" style="display:none; position:fixed; right:16px; bottom:16px; transform:none; background:#111; color:#fff; border-radius:4px 4px 0 0; padding:6px 8px; border:0; cursor:pointer; z-index:1500">Show sample</button>
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
function qs(sel, ctx){ return (ctx||document).querySelector(sel); }
|
||||
function qsa(sel, ctx){ return Array.from((ctx||document).querySelectorAll(sel)); }
|
||||
function updatePreview(){
|
||||
var sel = qs('select[name="design"]');
|
||||
var scaleInput = qs('input[name="scale"]');
|
||||
if(!sel) return;
|
||||
var design = String(sel.value || '1');
|
||||
var scale = parseFloat(scaleInput && scaleInput.value) || 1;
|
||||
|
||||
// Hide all fragments and reset transforms
|
||||
qsa('#sample-preview .design-preview').forEach(function(el){
|
||||
el.style.display = 'none';
|
||||
var c = el.querySelector('.toptrump-card');
|
||||
if(c){ c.style.transform = ''; c.style.position = ''; c.style.margin = ''; }
|
||||
});
|
||||
|
||||
// Find the fragment whose classList contains the design class (robust against nested classes)
|
||||
var active = null;
|
||||
qsa('#sample-preview .design-preview').forEach(function(el){
|
||||
if(el.classList && el.classList.contains('design-' + design)){
|
||||
active = el;
|
||||
}
|
||||
});
|
||||
if(!active){
|
||||
// fallback: try selecting by class selector
|
||||
active = qs('#sample-preview .design-' + design);
|
||||
}
|
||||
if(active){
|
||||
// Use empty string to let element use its natural display (block/div)
|
||||
active.style.display = '';
|
||||
var card = active.querySelector('.toptrump-card');
|
||||
if(card){
|
||||
card.style.margin = '0 auto';
|
||||
card.style.display = 'block';
|
||||
card.style.transformOrigin = 'top center';
|
||||
card.style.transform = 'scale(' + scale + ')';
|
||||
card.style.position = 'relative';
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
updatePreview();
|
||||
var sel = qs('select[name="design"]');
|
||||
var scaleInput = qs('input[name="scale"]');
|
||||
if(sel) sel.addEventListener('change', updatePreview);
|
||||
if(scaleInput) scaleInput.addEventListener('input', updatePreview);
|
||||
|
||||
// Select all / none handling for card checkboxes
|
||||
var selectAll = qs('#select-all-cards');
|
||||
function updateSelectAllState(){
|
||||
var boxes = qsa('input[type="checkbox"][name="cards"]');
|
||||
if(!selectAll || !boxes.length) return;
|
||||
var checkedCount = boxes.filter(function(b){ return b.checked; }).length;
|
||||
selectAll.checked = (checkedCount === boxes.length);
|
||||
selectAll.indeterminate = (checkedCount > 0 && checkedCount < boxes.length);
|
||||
}
|
||||
// initialize state
|
||||
updateSelectAllState();
|
||||
if(selectAll){
|
||||
selectAll.addEventListener('change', function(){
|
||||
var boxes = qsa('input[type="checkbox"][name="cards"]');
|
||||
boxes.forEach(function(b){ b.checked = selectAll.checked; });
|
||||
selectAll.indeterminate = false;
|
||||
});
|
||||
}
|
||||
// Keep select-all updated when individual checkboxes change
|
||||
qsa('input[type="checkbox"][name="cards"]').forEach(function(b){
|
||||
b.addEventListener('change', updateSelectAllState);
|
||||
});
|
||||
// Hide/show controls for the floating panel
|
||||
var hideBtn = qs('#sample-hide-btn');
|
||||
var panel = qs('#sample-panel');
|
||||
var showTab = qs('#sample-show-tab');
|
||||
if(hideBtn && panel && showTab){
|
||||
hideBtn.addEventListener('click', function(){
|
||||
panel.style.display = 'none';
|
||||
showTab.style.display = 'block';
|
||||
try{ localStorage.setItem('samplePanelHidden','1'); }catch(e){}
|
||||
console.log('Sample panel hidden');
|
||||
});
|
||||
showTab.addEventListener('click', function(){
|
||||
panel.style.display = 'block';
|
||||
showTab.style.display = 'none';
|
||||
try{ localStorage.removeItem('samplePanelHidden'); }catch(e){}
|
||||
// ensure preview updated when re-shown
|
||||
updatePreview();
|
||||
console.log('Sample panel shown');
|
||||
});
|
||||
// Restore state (default: visible)
|
||||
try{
|
||||
if(localStorage.getItem('samplePanelHidden')){
|
||||
panel.style.display = 'none';
|
||||
showTab.style.display = 'block';
|
||||
} else {
|
||||
panel.style.display = 'block';
|
||||
showTab.style.display = 'none';
|
||||
}
|
||||
}catch(e){}
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,172 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Printable cards</title>
|
||||
<style>
|
||||
/* Canonical card size for printable view */
|
||||
:root{ --card-width: 83mm; --card-height: 130mm; }
|
||||
/* A4 portrait: 210mm x 297mm. Use mm units to better match print sizing. */
|
||||
@page { size: A4 portrait; margin: 12mm; }
|
||||
html,body{height:100%; margin:0; padding:0;}
|
||||
body{font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; color:#111}
|
||||
.sheet{width:210mm; min-height:297mm; box-sizing:border-box; padding:6mm; display:block}
|
||||
.cards-grid{display:grid; gap:8mm; grid-auto-rows: var(--card-height); justify-content:center}
|
||||
.print-card{break-inside:avoid; page-break-inside:avoid; display:flex; justify-content:center; align-items:center;}
|
||||
/* Scale the toptrump-card down a bit for printing */
|
||||
.toptrump-card{box-shadow:none; border:none;}
|
||||
/* Card sizes are controlled by the canonical variables in
|
||||
`_card_hero_styles.html` so preview and printable views keep the
|
||||
same internal layout. The printable layout uses the same fixed
|
||||
card dimensions and arranges them per page using the grid. */
|
||||
/* Ensure hero images scale inside print cards */
|
||||
.print-card .hero-image{height:auto; min-height:40mm;}
|
||||
.print-card img.card-hero-img{max-width:100%; height:auto; object-fit:contain}
|
||||
/* Print fallback: show actual <img> fallbacks (added into fragments) during printing
|
||||
because some browsers do not print CSS background-images by default. */
|
||||
.print-card .print-hero-bg{display:none}
|
||||
/* DEBUG: temporarily force fallback <img> visible in all modes to verify rendering */
|
||||
.print-card .print-hero-bg.debug-force-visible{display:block !important; outline:2px solid rgba(255,0,0,0.8);}
|
||||
@media print{
|
||||
/* Hide CSS background-images and show the <img> fallback for reliability */
|
||||
.print-card .hero-image{background-image:none !important}
|
||||
.print-card .print-hero-bg{display:block !important; position:absolute; inset:0; width:100%; height:100%; object-fit:cover; z-index:0}
|
||||
/* Ensure card content layers above the fallback background */
|
||||
.print-card .toptrump-card{position:relative; z-index:2}
|
||||
|
||||
/* For non-design-4 cards keep the same absolute centering used in preview
|
||||
so the dinosaur image sits in the same place (the inline transform
|
||||
for scale is preserved). We avoid setting `transform` here so an
|
||||
inline `transform` (translate+scale) in the fragment will still apply. */
|
||||
.print-card .toptrump-card:not(.design-4) .hero-image img.card-hero-img {
|
||||
position:absolute;
|
||||
left:50%;
|
||||
top:50%;
|
||||
transform-origin:center;
|
||||
/* Reduce the minimum size used for print so the image doesn't
|
||||
intrude into neighbouring cards when printing multiple cards
|
||||
per sheet. 100% keeps the image filling the hero area but
|
||||
avoids the slight overlap caused by 110%. */
|
||||
min-width:100%;
|
||||
min-height:100%;
|
||||
width:auto;
|
||||
height:auto;
|
||||
object-fit:cover;
|
||||
z-index:3;
|
||||
}
|
||||
|
||||
/* Design-4 uses a different layout (object-fit:contain) in preview; preserve that */
|
||||
.print-card .toptrump-card.design-4 .card-hero-img{
|
||||
position:static !important;
|
||||
left:auto!important;
|
||||
top:auto!important;
|
||||
transform:none!important;
|
||||
display:block;
|
||||
margin:0 auto;
|
||||
width:120% !important;
|
||||
max-width:none !important;
|
||||
max-height:120% !important;
|
||||
object-fit:contain;
|
||||
z-index:3;
|
||||
}
|
||||
/* Remove all shadows and text-shadows when printing for clean print output */
|
||||
.toptrump-card, .toptrump-card * { box-shadow: none !important; text-shadow: none !important; }
|
||||
|
||||
/* Draw cut guides directly on the card element so they align with
|
||||
the card area even if the grid cell centers the card. Using a
|
||||
pseudo-element on `.toptrump-card` guarantees the guide matches
|
||||
the visible card box exactly. Add a small padding so the dashed
|
||||
line doesn't sit flush against the printed card edge. */
|
||||
:root{ --cut-padding: 3mm; }
|
||||
.print-card{ position: relative; }
|
||||
.print-card .toptrump-card{ position:relative; overflow: visible !important; }
|
||||
.print-card .toptrump-card::after{
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: calc(-1 * var(--cut-padding));
|
||||
left: calc(-1 * var(--cut-padding));
|
||||
width: calc(100% + calc(var(--cut-padding) * 2));
|
||||
height: calc(100% + calc(var(--cut-padding) * 2));
|
||||
border: 0.45mm dashed rgba(0,0,0,0.45);
|
||||
pointer-events: none;
|
||||
box-sizing: border-box;
|
||||
border-radius: calc(var(--cut-padding) + 0px);
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
/* Small caption */
|
||||
.caption{font-size:12px; color:#333; margin-top:6px}
|
||||
@media print{
|
||||
body{background:transparent}
|
||||
.sheet{box-shadow:none}
|
||||
.no-print{display:none}
|
||||
}
|
||||
</style>
|
||||
{% include 'cards/_card_hero_styles.html' %}
|
||||
</head>
|
||||
<body>
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; padding:8px 12px;">
|
||||
<div>
|
||||
<a class="no-print" href="{% url 'cards:print' %}" style="text-decoration:none; color:#111; font-weight:700;">← Back to selection</a>
|
||||
</div>
|
||||
<div>
|
||||
<button class="no-print" onclick="window.print();" style="padding:8px 12px; font-weight:700;">Print</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% for page_cards in pages %}
|
||||
<div class="sheet">
|
||||
<div class="cards-grid" style="grid-template-columns: repeat({{ columns|default:2 }}, var(--card-width)); gap:8mm;">
|
||||
{% for card in page_cards %}
|
||||
<div class="print-card">
|
||||
<div class="card-canonical">
|
||||
{% if design == 1 %}
|
||||
{% include 'cards/_card_inner_design1.html' %}
|
||||
{% elif design == 2 %}
|
||||
{% include 'cards/_card_inner_design2.html' %}
|
||||
{% elif design == 3 %}
|
||||
{% include 'cards/_card_inner_design3.html' %}
|
||||
{% elif design == 4 %}
|
||||
{% include 'cards/_card_inner_design4.html' %}
|
||||
{% elif design == 5 %}
|
||||
{% include 'cards/_card_inner_design5.html' %}
|
||||
{% else %}
|
||||
<div class="toptrump-card design-{{ design }}">
|
||||
<div class="hero-image" style="background:{% if card.background and card.background.image %}url('{{ card.background.image.url }}'){% elif background and background.image %}url('{{ background.image.url }}'){% endif %}; background-size:cover; background-position:center">
|
||||
{% if card.background and card.background.image or background and background.image %}
|
||||
<img class="print-hero-bg" src="{% if card.background and card.background.image %}{{ card.background.image.url }}{% else %}{{ background.image.url }}{% endif %}" alt="" aria-hidden="true" style="position:absolute; inset:0; width:100%; height:100%; object-fit:cover; z-index:0;">
|
||||
{% endif %}
|
||||
{% if card.image %}
|
||||
<img class="card-hero-img" src="{{ card.image.url }}" alt="{{ card.name }}">
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="padding:6px">
|
||||
<div style="font-weight:800; font-size:14pt">{{ card.name }}</div>
|
||||
<div style="margin-top:6px; font-size:11pt">{{ card.get_era_display }} — {{ card.get_diet_display }}</div>
|
||||
{% if card.facts_list %}
|
||||
<div class="caption">{{ card.facts_list|join:', ' }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</body>
|
||||
<script>
|
||||
// Debug helper: add `debug-force-visible` to print fallback images when ?debug_print=1
|
||||
(function(){
|
||||
try{
|
||||
if(window.location.search.indexOf('debug_print=1') !== -1){
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
Array.from(document.querySelectorAll('.print-card .print-hero-bg')).forEach(function(img){ img.classList.add('debug-force-visible'); });
|
||||
});
|
||||
}
|
||||
}catch(e){}
|
||||
})();
|
||||
</script>
|
||||
</html>
|
||||
@@ -9,8 +9,19 @@ urlpatterns = [
|
||||
path('<int:pk>/edit/', views.card_edit, name='edit'),
|
||||
path('<int:pk>/delete/', views.card_delete, name='delete'),
|
||||
path('preview/<int:pk>/', views.preview_card, name='preview'),
|
||||
path('print/', views.print_cards, name='print'),
|
||||
path('print/preview/', views.print_preview, name='print_preview'),
|
||||
path('backgrounds/', views.background_list, name='backgrounds'),
|
||||
path('backgrounds/create/', views.background_create, name='background_create'),
|
||||
path('backgrounds/<int:pk>/edit/', views.background_edit, name='background_edit'),
|
||||
path('backgrounds/download-all/', views.backgrounds_download_all, name='backgrounds_download_all'),
|
||||
path('backgrounds/<int:pk>/activate/', views.background_activate, name='background_activate'),
|
||||
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'),
|
||||
path('export-table/', views.export_table, name='export_table'),
|
||||
path('import/paste/', views.import_from_paste, name='import_paste'),
|
||||
path('import/apply/', views.apply_bulk_import, name='import_apply_bulk'),
|
||||
|
||||
]
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
from django.shortcuts import render, get_object_or_404, redirect
|
||||
from django.http import HttpResponse, HttpResponseBadRequest
|
||||
from django.template.loader import render_to_string
|
||||
import io
|
||||
import zipfile
|
||||
import os
|
||||
import json
|
||||
import csv
|
||||
from io import StringIO
|
||||
from .models import Dinosaur, BackgroundImage
|
||||
from .forms import DinosaurBulkFormSet
|
||||
from .forms import DinosaurForm
|
||||
from .forms import BackgroundImageForm
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
def is_htmx(request):
|
||||
@@ -11,61 +19,132 @@ def is_htmx(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
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
# Total (or filtered) card count for UI summary
|
||||
context['card_count'] = qs.count()
|
||||
|
||||
if is_htmx(request):
|
||||
return render(request, 'cards/_cards_list.html', {'cards': cards, 'background': background})
|
||||
return render(request, 'cards/list.html', {'cards': cards, 'background': background})
|
||||
return render(request, 'cards/_cards_list.html', context)
|
||||
return render(request, 'cards/list.html', context)
|
||||
|
||||
|
||||
def card_create(request):
|
||||
background = BackgroundImage.get_active() if BackgroundImage.objects.exists() else None
|
||||
backgrounds = BackgroundImage.objects.all()
|
||||
|
||||
if request.method == 'POST':
|
||||
form = DinosaurForm(request.POST, request.FILES)
|
||||
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):
|
||||
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
|
||||
form_html = render_to_string('cards/_form.html', {'form': DinosaurForm()}, request=request)
|
||||
oob = f'<div id="htmx-form" hx-swap-oob="true">{form_html}</div>'
|
||||
# Clear the modal via out-of-band so the modal-root is emptied
|
||||
oob = '<div id="modal-root" hx-swap-oob="true"></div>'
|
||||
return HttpResponse(card_html + oob)
|
||||
return redirect('cards:list')
|
||||
else:
|
||||
if is_htmx(request):
|
||||
# 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)
|
||||
oob = f'<div id="htmx-form" hx-swap-oob="true">{form_html}</div>'
|
||||
return HttpResponse(oob)
|
||||
# Return the form wrapped in a modal fragment so it updates the modal-root
|
||||
form_html = render_to_string('cards/_form_modal.html', {'form': form, 'background': background, 'backgrounds': backgrounds}, request=request)
|
||||
return HttpResponse(form_html)
|
||||
else:
|
||||
form = DinosaurForm()
|
||||
|
||||
return render(request, 'cards/_form.html', {'form': form, 'background': background})
|
||||
# If this is an HTMX request, return the modal-wrapped form so it can be
|
||||
# injected into `#modal-root`. Otherwise return the standalone form page.
|
||||
if is_htmx(request):
|
||||
return render(request, 'cards/_form_modal.html', {'form': form, 'background': background, 'backgrounds': backgrounds})
|
||||
return render(request, 'cards/_form.html', {'form': form, 'background': background, 'backgrounds': backgrounds})
|
||||
|
||||
|
||||
def card_edit(request, pk):
|
||||
card = get_object_or_404(Dinosaur, pk=pk)
|
||||
background = BackgroundImage.get_active() if BackgroundImage.objects.exists() else None
|
||||
backgrounds = BackgroundImage.objects.all()
|
||||
if request.method == 'POST':
|
||||
form = DinosaurForm(request.POST, request.FILES, instance=card)
|
||||
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):
|
||||
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
|
||||
oob = '<div id="htmx-form" hx-swap-oob="true"></div>'
|
||||
# Clear the modal via OOB so the modal-root is emptied
|
||||
oob = '<div id="modal-root" hx-swap-oob="true"></div>'
|
||||
return HttpResponse(card_html + oob)
|
||||
return redirect('cards:list')
|
||||
else:
|
||||
if is_htmx(request):
|
||||
form_html = render_to_string('cards/_form.html', {'form': form, 'card': card}, request=request)
|
||||
oob = f'<div id="htmx-form" hx-swap-oob="true">{form_html}</div>'
|
||||
return HttpResponse(oob)
|
||||
form_html = render_to_string('cards/_form_modal.html', {'form': form, 'card': card, 'background': background, 'backgrounds': backgrounds}, request=request)
|
||||
return HttpResponse(form_html)
|
||||
else:
|
||||
form = DinosaurForm(instance=card)
|
||||
|
||||
return render(request, 'cards/_form.html', {'form': form, 'card': card, 'background': background})
|
||||
if is_htmx(request):
|
||||
return render(request, 'cards/_form_modal.html', {'form': form, 'card': card, 'background': background, 'backgrounds': backgrounds})
|
||||
return render(request, 'cards/_form.html', {'form': form, 'card': card, 'background': background, 'backgrounds': backgrounds})
|
||||
|
||||
|
||||
def card_delete(request, pk):
|
||||
@@ -73,7 +152,10 @@ def card_delete(request, pk):
|
||||
if request.method == 'POST':
|
||||
card.delete()
|
||||
if is_htmx(request):
|
||||
return HttpResponse(status=204)
|
||||
# Return an empty 200 response so HTMX can replace the
|
||||
# targeted element (e.g. via `hx-swap="outerHTML"`) and
|
||||
# remove it from the DOM immediately.
|
||||
return HttpResponse('')
|
||||
return redirect('cards:list')
|
||||
return HttpResponseBadRequest('Only POST allowed')
|
||||
|
||||
@@ -87,6 +169,35 @@ def background_list(request):
|
||||
return render(request, 'cards/backgrounds_list.html', {'backgrounds': backgrounds, 'form': form})
|
||||
|
||||
|
||||
def backgrounds_download_all(request):
|
||||
"""Provide a ZIP archive download containing all uploaded background images.
|
||||
|
||||
Creates the ZIP in-memory and returns it as an attachment. Skips missing
|
||||
files gracefully.
|
||||
"""
|
||||
backgrounds = BackgroundImage.objects.all()
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, 'w', compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for bg in backgrounds:
|
||||
if not getattr(bg, 'image', None):
|
||||
continue
|
||||
try:
|
||||
filepath = bg.image.path
|
||||
except Exception:
|
||||
# If storage backend doesn't expose a local path, skip.
|
||||
continue
|
||||
if not os.path.exists(filepath):
|
||||
continue
|
||||
arcname = os.path.basename(bg.image.name) if getattr(bg.image, 'name', None) else f'background-{bg.pk}'
|
||||
# Prefix with id to avoid duplicate names
|
||||
arcname = f"{bg.pk}_{arcname}"
|
||||
zf.write(filepath, arcname)
|
||||
buffer.seek(0)
|
||||
resp = HttpResponse(buffer.getvalue(), content_type='application/zip')
|
||||
resp['Content-Disposition'] = 'attachment; filename=backgrounds.zip'
|
||||
return resp
|
||||
|
||||
|
||||
def background_create(request):
|
||||
if request.method != 'POST':
|
||||
return HttpResponseBadRequest('Only POST allowed')
|
||||
@@ -130,14 +241,636 @@ def background_delete(request, pk):
|
||||
bg = get_object_or_404(BackgroundImage, pk=pk)
|
||||
bg.delete()
|
||||
if is_htmx(request):
|
||||
return HttpResponse(status=204)
|
||||
# See comment in `card_delete`: return empty body so HTMX can
|
||||
# remove the corresponding element via `hx-swap="outerHTML"`.
|
||||
return HttpResponse('')
|
||||
return redirect('cards:backgrounds')
|
||||
|
||||
|
||||
def background_edit(request, pk):
|
||||
"""Edit an existing BackgroundImage's metadata (title, image, is_active).
|
||||
|
||||
Designed to work with HTMX: GET returns a small edit form fragment which
|
||||
replaces the background card. POST saves and returns the updated
|
||||
backgrounds list fragment so the UI stays in sync.
|
||||
"""
|
||||
bg = get_object_or_404(BackgroundImage, pk=pk)
|
||||
if request.method == 'POST':
|
||||
form = BackgroundImageForm(request.POST, request.FILES, instance=bg)
|
||||
if form.is_valid():
|
||||
bg = form.save()
|
||||
# If marked active, unset others
|
||||
if bg.is_active:
|
||||
BackgroundImage.objects.exclude(pk=bg.pk).update(is_active=False)
|
||||
if is_htmx(request):
|
||||
list_html = render_to_string('cards/_backgrounds_list.html', {'backgrounds': BackgroundImage.objects.all(), 'form': BackgroundImageForm()}, request=request)
|
||||
return HttpResponse(list_html)
|
||||
return redirect('cards:backgrounds')
|
||||
else:
|
||||
if is_htmx(request):
|
||||
form_html = render_to_string('cards/_background_title_form.html', {'bg': bg, 'form': form}, request=request)
|
||||
return HttpResponse(form_html)
|
||||
return render(request, 'cards/backgrounds_list.html', {'backgrounds': BackgroundImage.objects.all(), 'form': form})
|
||||
else:
|
||||
form = BackgroundImageForm(instance=bg)
|
||||
if is_htmx(request):
|
||||
return render(request, 'cards/_background_title_form.html', {'bg': bg, 'form': form})
|
||||
return render(request, 'cards/backgrounds_list.html', {'backgrounds': BackgroundImage.objects.all(), 'form': form})
|
||||
|
||||
|
||||
def preview_card(request, pk):
|
||||
"""Render a full preview of a card. HTMX loads the modal fragment into #modal-root."""
|
||||
card = get_object_or_404(Dinosaur, pk=pk)
|
||||
background = BackgroundImage.get_active() if BackgroundImage.objects.exists() else None
|
||||
# Support multiple visual designs selectable via `?design=1..5`.
|
||||
try:
|
||||
design = int(request.GET.get('design', '1'))
|
||||
except Exception:
|
||||
design = 1
|
||||
if design < 1 or design > 5:
|
||||
design = 1
|
||||
template_name = f'cards/_card_preview_design{design}.html'
|
||||
template_name = f'cards/_card_preview_design{design}.html'
|
||||
# Compute previous/next PKs using a stable ordering (by name)
|
||||
qs = list(Dinosaur.objects.order_by('name').values_list('pk', flat=True))
|
||||
prev_pk = None
|
||||
next_pk = None
|
||||
try:
|
||||
idx = qs.index(card.pk)
|
||||
if idx > 0:
|
||||
prev_pk = qs[idx - 1]
|
||||
if idx < len(qs) - 1:
|
||||
next_pk = qs[idx + 1]
|
||||
except ValueError:
|
||||
prev_pk = None
|
||||
next_pk = None
|
||||
|
||||
context = {'card': card, 'background': background, 'design': design, 'fragment_template': template_name, 'prev_pk': prev_pk, 'next_pk': next_pk}
|
||||
if is_htmx(request):
|
||||
return render(request, 'cards/_card_preview.html', {'card': card, 'background': background})
|
||||
return render(request, 'cards/preview.html', {'card': card, 'background': background})
|
||||
return render(request, template_name, context)
|
||||
# For non-HTMX full page preview, render the main list page and include the
|
||||
# preview fragment so the page shows the normal card list with the modal
|
||||
# already injected. This keeps the surrounding UI consistent when users
|
||||
# open a preview URL directly.
|
||||
qs = Dinosaur.objects.all()
|
||||
list_context = {
|
||||
'cards': qs,
|
||||
'background': background,
|
||||
'eras': Dinosaur.ERA_CHOICES,
|
||||
'diets': Dinosaur.DIET_CHOICES,
|
||||
'current_q': '',
|
||||
'current_era': 'all',
|
||||
'current_diet': 'all',
|
||||
'current_sort': '',
|
||||
'current_order': 'asc',
|
||||
'card_count': qs.count(),
|
||||
}
|
||||
# Render the fragment HTML server-side and pass it to the list template so
|
||||
# it can be injected into `#modal-root` immediately on load.
|
||||
fragment_html = render_to_string(template_name, context, request=request)
|
||||
list_context['modal_fragment'] = fragment_html
|
||||
return render(request, 'cards/list.html', list_context)
|
||||
|
||||
|
||||
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)
|
||||
card_count = qs.count()
|
||||
return render(request, 'cards/bulk_edit.html', {'formset': formset, 'backgrounds': backgrounds, 'card_count': card_count})
|
||||
|
||||
|
||||
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()
|
||||
card_count = qs.count()
|
||||
return render(request, 'cards/bulk_edit.html', {'formset': formset, 'backgrounds': backgrounds, 'card_count': card_count})
|
||||
|
||||
|
||||
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')
|
||||
|
||||
|
||||
def export_table(request):
|
||||
"""Render a page containing a TSV/CSV/Markdown table of all dinosaurs that
|
||||
can be copied and pasted into an LLM web interface."""
|
||||
qs = Dinosaur.objects.order_by('name')
|
||||
# produce a JSON array of objects for easy copy/paste
|
||||
rows = []
|
||||
for d in qs:
|
||||
rows.append({
|
||||
'pk': d.pk,
|
||||
'name': d.name,
|
||||
'era': d.era,
|
||||
'era_display': d.get_era_display(),
|
||||
'diet': d.diet,
|
||||
'diet_display': d.get_diet_display(),
|
||||
'speed_kmh': d.speed,
|
||||
'weight_kg': d.weight,
|
||||
'height_m': float(d.height),
|
||||
'intelligence': d.intelligence,
|
||||
'facts': d.facts_list,
|
||||
})
|
||||
import json as _json
|
||||
json_text = _json.dumps(rows, ensure_ascii=False, indent=2)
|
||||
return render(request, 'cards/export_table.html', {'json_text': json_text})
|
||||
|
||||
|
||||
def print_cards(request):
|
||||
"""Selection UI where the user picks which cards to print and which design."""
|
||||
qs = Dinosaur.objects.order_by('name')
|
||||
background = BackgroundImage.get_active() if BackgroundImage.objects.exists() else None
|
||||
return render(request, 'cards/print.html', {'cards': qs, 'background': background})
|
||||
|
||||
|
||||
def print_preview(request):
|
||||
"""Render a printable view of selected cards.
|
||||
|
||||
POST params:
|
||||
- cards: repeated card pks
|
||||
- design: design number
|
||||
- scale: optional scale factor
|
||||
|
||||
Returns an HTML A4-friendly document that the browser can print or save as PDF.
|
||||
"""
|
||||
# Accept POST or GET for easier testing
|
||||
data = request.POST if request.method == 'POST' else request.GET
|
||||
pks = data.getlist('cards')
|
||||
if not pks:
|
||||
return HttpResponseBadRequest('No cards selected')
|
||||
try:
|
||||
design = int(data.get('design', '1'))
|
||||
except Exception:
|
||||
design = 1
|
||||
scale = float(data.get('scale', '1')) if data.get('scale') else 1.0
|
||||
try:
|
||||
per_page = int(data.get('per_page', '2'))
|
||||
except Exception:
|
||||
per_page = 2
|
||||
if per_page not in (1, 2, 4):
|
||||
per_page = 2
|
||||
# Fetch cards preserving the given order
|
||||
cards = list(Dinosaur.objects.filter(pk__in=pks))
|
||||
# Reorder according to provided pks
|
||||
pk_to_card = {str(c.pk): c for c in cards}
|
||||
ordered = [pk_to_card[pk] for pk in pks if pk in pk_to_card]
|
||||
|
||||
# Build pages: per_page cards per A4 page
|
||||
pages = []
|
||||
for i in range(0, len(ordered), per_page):
|
||||
pages.append(ordered[i:i+per_page])
|
||||
|
||||
# -- Top-trump calculation across the full selection -----------------
|
||||
# For each card compute its best stat percent (0-100) and the stat(s)
|
||||
# that achieve that percent. Then find the global maximum best-value and
|
||||
# mark any card whose best-value equals that maximum as a top-trump.
|
||||
# Compute selection maxima for speed/weight/height/intelligence so we
|
||||
# can scale these metrics relative to the selected cards. This prevents
|
||||
# many large absolute values from all clamping to 100% and getting 5★.
|
||||
max_speed = 0.0
|
||||
max_weight = 0.0
|
||||
max_height = 0.0
|
||||
max_intel = 0.0
|
||||
for c in ordered:
|
||||
try:
|
||||
sv = float(c.speed or 0)
|
||||
except Exception:
|
||||
sv = 0.0
|
||||
try:
|
||||
wv = float(c.weight or 0)
|
||||
except Exception:
|
||||
wv = 0.0
|
||||
try:
|
||||
hv = float(c.height or 0)
|
||||
except Exception:
|
||||
hv = 0.0
|
||||
try:
|
||||
iv = float(c.intelligence or 0)
|
||||
except Exception:
|
||||
iv = 0.0
|
||||
if sv > max_speed:
|
||||
max_speed = sv
|
||||
if wv > max_weight:
|
||||
max_weight = wv
|
||||
if hv > max_height:
|
||||
max_height = hv
|
||||
if iv > max_intel:
|
||||
max_intel = iv
|
||||
|
||||
all_best_values = []
|
||||
for c in ordered:
|
||||
# raw values
|
||||
try:
|
||||
raw_s = float(c.speed or 0)
|
||||
except Exception:
|
||||
raw_s = 0.0
|
||||
try:
|
||||
raw_w = float(c.weight or 0)
|
||||
except Exception:
|
||||
raw_w = 0.0
|
||||
try:
|
||||
raw_h = float(c.height or 0)
|
||||
except Exception:
|
||||
raw_h = 0.0
|
||||
try:
|
||||
raw_it = float(c.intelligence or 0)
|
||||
except Exception:
|
||||
raw_it = 0.0
|
||||
|
||||
# Compute selection-relative percents so the largest in the
|
||||
# selection maps to 100% for each metric.
|
||||
if max_speed > 0:
|
||||
s = min(100, int(round((raw_s / max_speed) * 100)))
|
||||
else:
|
||||
s = 0
|
||||
if max_weight > 0:
|
||||
w = min(100, int(round((raw_w / max_weight) * 100)))
|
||||
else:
|
||||
w = 0
|
||||
if max_height > 0:
|
||||
h = min(100, int(round((raw_h / max_height) * 100)))
|
||||
else:
|
||||
h = 0
|
||||
if max_intel > 0:
|
||||
it = min(100, int(round((raw_it / max_intel) * 100)))
|
||||
else:
|
||||
it = 0
|
||||
|
||||
# Attach adjusted star visuals for the printable preview so
|
||||
# templates reflect selection-relative scaling. These instance
|
||||
# attributes shadow the class properties during template rendering.
|
||||
setattr(c, 'speed_star_types_adj', c._star_types_from_percent(s))
|
||||
setattr(c, 'speed_stars_adj', c._stars_string(s))
|
||||
setattr(c, 'weight_star_types_adj', c._star_types_from_percent(w))
|
||||
setattr(c, 'weight_stars_adj', c._stars_string(w))
|
||||
setattr(c, 'height_star_types_adj', c._star_types_from_percent(h))
|
||||
setattr(c, 'height_stars_adj', c._stars_string(h))
|
||||
setattr(c, 'intelligence_star_types_adj', c._star_types_from_percent(it))
|
||||
setattr(c, 'intelligence_stars_adj', c._stars_string(it))
|
||||
|
||||
setattr(c, 'speed_percent_adj', s)
|
||||
setattr(c, 'weight_percent_adj', w)
|
||||
setattr(c, 'height_percent_adj', h)
|
||||
setattr(c, 'intelligence_percent_adj', it)
|
||||
|
||||
# map names to values (independent stats) using adjusted percents
|
||||
stats = {'speed': s, 'weight': w, 'intelligence': it, 'height': h}
|
||||
max_val = max(stats.values())
|
||||
best_stats = [name for name, val in stats.items() if val == max_val]
|
||||
|
||||
# attach computed attributes so templates can read them
|
||||
setattr(c, 'best_value', max_val)
|
||||
setattr(c, 'best_stats', best_stats)
|
||||
all_best_values.append(max_val)
|
||||
# determine global maximum and mark cards as top-trump if they match
|
||||
global_max = max(all_best_values) if all_best_values else 0
|
||||
for c in ordered:
|
||||
setattr(c, 'is_top_trump', getattr(c, 'best_value', 0) == global_max)
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
# Determine grid columns for layout: use 1 column for 1-up, 2 columns for 2/4-up
|
||||
columns = 1 if per_page == 1 else 2
|
||||
|
||||
# Include the active background (if any) so fragments that fall back to
|
||||
# a global `background` variable can render correctly in the printable view.
|
||||
background = BackgroundImage.get_active() if BackgroundImage.objects.exists() else None
|
||||
|
||||
return render(request, 'cards/printable.html', {
|
||||
'pages': pages,
|
||||
'design': design,
|
||||
'scale': scale,
|
||||
'background': background,
|
||||
'per_page': per_page,
|
||||
'columns': columns,
|
||||
})
|
||||
|
||||
|
||||
def _parse_pasted_input(text):
|
||||
"""Try to parse pasted LLM output into a list of suggestion dicts.
|
||||
|
||||
Accepts JSON array/object, newline-delimited JSON objects, or CSV/TSV with headers.
|
||||
Each suggestion should contain at least a `name` or `pk` and optional fields:
|
||||
`speed_kmh`, `weight_kg`, `height_m`, `intelligence_score`, `facts` (list or string).
|
||||
Returns list of dicts.
|
||||
"""
|
||||
text = (text or '').strip()
|
||||
if not text:
|
||||
return []
|
||||
# Try JSON
|
||||
try:
|
||||
data = json.loads(text)
|
||||
if isinstance(data, dict):
|
||||
return [data]
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
except Exception:
|
||||
pass
|
||||
# Try newline-delimited JSON
|
||||
lines = [l.strip() for l in text.splitlines() if l.strip()]
|
||||
if len(lines) > 1:
|
||||
maybe = []
|
||||
for l in lines:
|
||||
try:
|
||||
obj = json.loads(l)
|
||||
maybe.append(obj)
|
||||
except Exception:
|
||||
maybe = []
|
||||
break
|
||||
if maybe:
|
||||
return maybe
|
||||
# Try CSV/TSV
|
||||
try:
|
||||
sniffer = csv.Sniffer()
|
||||
dialect = sniffer.sniff(text[:1024])
|
||||
f = StringIO(text)
|
||||
reader = csv.DictReader(f, dialect=dialect)
|
||||
out = []
|
||||
for row in reader:
|
||||
out.append({k.strip(): (v.strip() if v is not None else '') for k, v in row.items()})
|
||||
if out:
|
||||
return out
|
||||
except Exception:
|
||||
pass
|
||||
# As last resort return single-line fallback
|
||||
return [{'raw': text}]
|
||||
|
||||
|
||||
def import_from_paste(request):
|
||||
"""Endpoint that accepts pasted LLM output and returns a preview fragment."""
|
||||
if request.method != 'POST':
|
||||
return HttpResponseBadRequest('Only POST allowed')
|
||||
text = request.POST.get('pasted', '')
|
||||
parsed = _parse_pasted_input(text)
|
||||
# Normalize parsed entries into suggestion dicts with named fields
|
||||
suggestions = []
|
||||
for item in parsed:
|
||||
# lower-case keys for convenience
|
||||
entry = {k.lower(): v for k, v in item.items()} if isinstance(item, dict) else {'raw': str(item)}
|
||||
s = {
|
||||
'pk': entry.get('pk') or entry.get('id'),
|
||||
'era': entry.get('era'),
|
||||
'diet': entry.get('diet'),
|
||||
'name': entry.get('name'),
|
||||
'speed_kmh': entry.get('speed_kmh') or entry.get('speed') or entry.get('speed_km/h'),
|
||||
'weight_kg': entry.get('weight_kg') or entry.get('weight') or entry.get('mass_kg'),
|
||||
'height_m': entry.get('height_m') or entry.get('height') or entry.get('height_meters'),
|
||||
'intelligence_score': entry.get('intelligence_score') or entry.get('intelligence') or entry.get('iq'),
|
||||
'facts': entry.get('facts') or entry.get('fact') or entry.get('raw'),
|
||||
'sources': entry.get('sources') or entry.get('source')
|
||||
}
|
||||
# convert facts string to list if needed
|
||||
if isinstance(s['facts'], str):
|
||||
parts = [p.strip() for p in s['facts'].split('\n') if p.strip()]
|
||||
if len(parts) == 1 and '|' in parts[0]:
|
||||
parts = [p.strip() for p in parts[0].split('|') if p.strip()]
|
||||
s['facts'] = parts
|
||||
suggestions.append(s)
|
||||
|
||||
# Build contextual preview: match suggestions to existing dinosaurs and compute field-level diffs
|
||||
from .models import Dinosaur
|
||||
|
||||
def _map_choice(val, choices):
|
||||
"""Map an incoming value (code or display) to the internal choice code.
|
||||
Returns the code or None if not found."""
|
||||
if val is None:
|
||||
return None
|
||||
s = str(val).strip()
|
||||
if not s:
|
||||
return None
|
||||
s_low = s.lower()
|
||||
# Try exact code match first
|
||||
for code, label in choices:
|
||||
if s_low == str(code).lower() or s_low == str(label).lower():
|
||||
return code
|
||||
return None
|
||||
|
||||
preview_items = []
|
||||
for idx, s in enumerate(suggestions):
|
||||
pk = s.get('pk')
|
||||
name = s.get('name')
|
||||
matched = None
|
||||
if pk:
|
||||
try:
|
||||
matched = Dinosaur.objects.get(pk=pk)
|
||||
except Dinosaur.DoesNotExist:
|
||||
matched = None
|
||||
if matched is None and name:
|
||||
try:
|
||||
matched = Dinosaur.objects.get(name__iexact=name)
|
||||
except Dinosaur.DoesNotExist:
|
||||
matched = None
|
||||
|
||||
# normalize proposed values for comparison
|
||||
def _to_float(v):
|
||||
try:
|
||||
return float(v)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
proposed = {
|
||||
'name': s.get('name'),
|
||||
'era': _map_choice(s.get('era'), Dinosaur.ERA_CHOICES),
|
||||
'diet': _map_choice(s.get('diet'), Dinosaur.DIET_CHOICES),
|
||||
'speed_kmh': _to_float(s.get('speed_kmh')),
|
||||
'weight_kg': _to_float(s.get('weight_kg')),
|
||||
'height_m': _to_float(s.get('height_m')),
|
||||
'intelligence_score': _to_float(s.get('intelligence_score')),
|
||||
'facts': s.get('facts')
|
||||
}
|
||||
|
||||
existing = None
|
||||
diffs = []
|
||||
if matched:
|
||||
existing = {
|
||||
'name': matched.name,
|
||||
'era': matched.era,
|
||||
'diet': matched.diet,
|
||||
'speed_kmh': float(matched.speed) if matched.speed is not None else None,
|
||||
'weight_kg': float(matched.weight) if matched.weight is not None else None,
|
||||
'height_m': float(matched.height) if matched.height is not None else None,
|
||||
'intelligence_score': float(matched.intelligence) if matched.intelligence is not None else None,
|
||||
'facts': matched.facts_list
|
||||
}
|
||||
# compare field-by-field (tolerant numeric comparison)
|
||||
for field in ['name', 'era', 'diet']:
|
||||
if proposed.get(field) is not None and str(proposed.get(field)) != str(existing.get(field)):
|
||||
diffs.append(field)
|
||||
for field in ['speed_kmh', 'weight_kg', 'height_m', 'intelligence_score']:
|
||||
pv = proposed.get(field)
|
||||
ev = existing.get(field)
|
||||
if pv is not None and ev is not None:
|
||||
try:
|
||||
if abs(float(pv) - float(ev)) > 0.0001:
|
||||
diffs.append(field)
|
||||
except Exception:
|
||||
diffs.append(field)
|
||||
elif pv is not None and ev is None:
|
||||
diffs.append(field)
|
||||
# facts: compare list vs list/string
|
||||
prop_facts = proposed.get('facts')
|
||||
if isinstance(prop_facts, str):
|
||||
prop_list = [l.strip() for l in prop_facts.splitlines() if l.strip()]
|
||||
elif isinstance(prop_facts, list):
|
||||
prop_list = [str(x).strip() for x in prop_facts if str(x).strip()]
|
||||
else:
|
||||
prop_list = []
|
||||
if prop_list and prop_list != existing.get('facts'):
|
||||
diffs.append('facts')
|
||||
|
||||
else:
|
||||
# New record: everything proposed counts as a diff
|
||||
existing = None
|
||||
diffs = [k for k, v in proposed.items() if v is not None and v != ""]
|
||||
|
||||
preview_items.append({'index': idx, 'suggestion': s, 'matched': matched, 'existing': existing, 'proposed': proposed, 'diffs': diffs})
|
||||
|
||||
suggestions_json = json.dumps(suggestions, ensure_ascii=False)
|
||||
return render(request, 'cards/_bulk_import_preview.html', {'preview_items': preview_items, 'suggestions_json': suggestions_json})
|
||||
|
||||
|
||||
def apply_bulk_import(request):
|
||||
"""Apply pasted suggestions. For now apply all suggestions provided in the
|
||||
`data` POST field (JSON array). Returns updated list fragment or a simple message."""
|
||||
if request.method != 'POST':
|
||||
return HttpResponseBadRequest('Only POST allowed')
|
||||
data = request.POST.get('data')
|
||||
if not data:
|
||||
return HttpResponseBadRequest('No data provided')
|
||||
try:
|
||||
parsed = json.loads(data)
|
||||
except Exception:
|
||||
return HttpResponseBadRequest('Failed to parse JSON data')
|
||||
|
||||
applied = 0
|
||||
def _to_float(v):
|
||||
try:
|
||||
return float(v)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
for idx, item in enumerate(parsed):
|
||||
name = item.get('name') if isinstance(item, dict) else None
|
||||
pk = item.get('pk') if isinstance(item, dict) else None
|
||||
d = None
|
||||
if pk:
|
||||
try:
|
||||
d = Dinosaur.objects.get(pk=pk)
|
||||
except Dinosaur.DoesNotExist:
|
||||
d = None
|
||||
if d is None and name:
|
||||
try:
|
||||
d = Dinosaur.objects.get(name__iexact=name)
|
||||
except Dinosaur.DoesNotExist:
|
||||
d = None
|
||||
# If no existing dinosaur matched, allow creation when user checked the create box
|
||||
create_requested = bool(request.POST.get(f'apply-{idx}-create'))
|
||||
if not d and create_requested:
|
||||
d = Dinosaur()
|
||||
is_new = True
|
||||
if not d:
|
||||
continue
|
||||
|
||||
# determine whether the user checked to apply this field for this item
|
||||
def _should_apply(field_name):
|
||||
return bool(request.POST.get(f'apply-{idx}-{field_name}'))
|
||||
|
||||
speed = _to_float(item.get('speed_kmh'))
|
||||
weight = _to_float(item.get('weight_kg'))
|
||||
height = _to_float(item.get('height_m'))
|
||||
intelligence = _to_float(item.get('intelligence_score') or item.get('intelligence'))
|
||||
|
||||
if speed is not None and _should_apply('speed_kmh'):
|
||||
d.speed = int(round(speed))
|
||||
if weight is not None and _should_apply('weight_kg'):
|
||||
d.weight = int(round(weight))
|
||||
if height is not None and _should_apply('height_m'):
|
||||
d.height = Decimal(str(round(height, 1)))
|
||||
if intelligence is not None and _should_apply('intelligence_score'):
|
||||
if 0 <= intelligence <= 5:
|
||||
d.intelligence = int(round((intelligence / 5.0) * 100))
|
||||
else:
|
||||
d.intelligence = int(round(max(0, min(100, intelligence))))
|
||||
|
||||
# Name
|
||||
if item.get('name') and _should_apply('name'):
|
||||
d.name = item.get('name')
|
||||
# Ensure new records have a name (fallback)
|
||||
if getattr(d, 'name', None) in (None, ''):
|
||||
d.name = item.get('name') or f'Imported Dinosaur {idx + 1}'
|
||||
|
||||
# Era and diet: accept code or display label
|
||||
if item.get('era') and _should_apply('era'):
|
||||
era_val = item.get('era')
|
||||
if isinstance(era_val, str):
|
||||
mapped = None
|
||||
for code, label in Dinosaur.ERA_CHOICES:
|
||||
if era_val.lower() == code.lower() or era_val.lower() == label.lower():
|
||||
mapped = code
|
||||
break
|
||||
if mapped:
|
||||
d.era = mapped
|
||||
else:
|
||||
d.era = era_val
|
||||
|
||||
if item.get('diet') and _should_apply('diet'):
|
||||
diet_val = item.get('diet')
|
||||
if isinstance(diet_val, str):
|
||||
mapped = None
|
||||
for code, label in Dinosaur.DIET_CHOICES:
|
||||
if diet_val.lower() == code.lower() or diet_val.lower() == label.lower():
|
||||
mapped = code
|
||||
break
|
||||
if mapped:
|
||||
d.diet = mapped
|
||||
else:
|
||||
d.diet = diet_val
|
||||
|
||||
# Facts
|
||||
if _should_apply('facts'):
|
||||
facts = item.get('facts')
|
||||
if isinstance(facts, list):
|
||||
d.facts = '\n'.join([str(x).strip() for x in facts if str(x).strip()])
|
||||
elif isinstance(facts, str):
|
||||
d.facts = '\n'.join([l.strip() for l in facts.splitlines() if l.strip()])
|
||||
|
||||
d.save()
|
||||
applied += 1
|
||||
|
||||
return HttpResponse(f'Applied updates to {applied} dinosaurs')
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 6.8 MiB |
|
After Width: | Height: | Size: 6.9 MiB |
|
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,2 +1,3 @@
|
||||
django
|
||||
Pillow
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<article class="card-item" id="card-{{ card.pk }}">
|
||||
<div class="toptrump-card">
|
||||
<div class="hero-image" style="background-image: url('{% if background and background.image %}{{ background.image.url }}{% endif %}');">
|
||||
{% 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;">
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="title-band">{{ card.name }}</div>
|
||||
<div class="card-body">
|
||||
<div class="subtitle is-7"><strong>Era:</strong> {{ card.get_era_display }} — <strong>Diet:</strong> {{ card.get_diet_display }}</div>
|
||||
{% if card.facts_list %}
|
||||
<ul class="fact-list is-size-7">
|
||||
{% for fact in card.facts_list|slice:":2" %}
|
||||
<li>{{ fact }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
<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-label">Weight</div><div class="stat-bar"><div class="stat-fill" style="width:{{ card.weight }}%"></div></div></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>
|
||||
<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-primary" hx-get="{% url 'cards:preview' card.pk %}" hx-target="#modal-root" hx-swap="innerHTML">Preview</button>
|
||||
<button class="button is-small is-danger" hx-post="{% url 'cards:delete' card.pk %}" hx-confirm="Delete this card?" hx-swap="none">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -1,10 +0,0 @@
|
||||
{% load static %}
|
||||
<div class="columns is-multiline">
|
||||
{% for card in cards %}
|
||||
<div class="column is-3-desktop is-4-tablet is-6-mobile">
|
||||
{% include 'cards/_card_item.html' with card=card background=background %}
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="notification is-warning">No dinosaurs yet — add one!</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -1,115 +0,0 @@
|
||||
<div class="card-form 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">
|
||||
{% 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">
|
||||
{% endif %}
|
||||
{% csrf_token %}
|
||||
{{ form.non_field_errors }}
|
||||
<div class="field">
|
||||
<label class="label">{{ form.name.label }}</label>
|
||||
<div class="control">
|
||||
{{ form.name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field is-horizontal">
|
||||
<div class="field-body">
|
||||
<div class="field">
|
||||
<label class="label">{{ form.era.label }}</label>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">{{ form.era }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ form.diet.label }}</label>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">{{ form.diet }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">{{ form.description.label }}</label>
|
||||
<div class="control">
|
||||
{{ form.description }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">{{ form.facts.label }}</label>
|
||||
<div class="control">
|
||||
{{ form.facts }}
|
||||
<p class="help">One fact per line — will be shown as bullet points on the card.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">{{ form.image.label }}</label>
|
||||
<div class="control">
|
||||
<div class="file has-name">
|
||||
<label class="file-label">
|
||||
{{ form.image }}
|
||||
<span class="file-cta">
|
||||
<span class="file-icon">📁</span>
|
||||
<span class="file-label">Choose a file…</span>
|
||||
</span>
|
||||
<span class="file-name" style="margin-left:0.5rem">No file chosen</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
const script = document.currentScript;
|
||||
const formRoot = script && script.parentElement ? script.parentElement : document;
|
||||
const fileInputs = formRoot.querySelectorAll('input[type=file]');
|
||||
fileInputs.forEach(function(fi){
|
||||
fi.addEventListener('change', function(){
|
||||
const wrapper = fi.closest('.file');
|
||||
if(!wrapper) return;
|
||||
const label = wrapper.querySelector('.file-name');
|
||||
if(!label) return;
|
||||
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;
|
||||
});
|
||||
const outerLabel = fi.closest('.file').querySelector('.file-label');
|
||||
if(outerLabel && !outerLabel.contains(fi)){
|
||||
outerLabel.addEventListener('click', function(e){ fi.click(); });
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<div class="field is-horizontal">
|
||||
<div class="field-body">
|
||||
<div class="field">
|
||||
<label class="label">{{ form.speed.label }}</label>
|
||||
<div class="control">{{ form.speed }}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ form.weight.label }}</label>
|
||||
<div class="control">{{ form.weight }}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ form.intelligence.label }}</label>
|
||||
<div class="control">{{ form.intelligence }}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ form.height.label }}</label>
|
||||
<div class="control">{{ form.height }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="submit">Save</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button class="button is-light" type="button" onclick="document.getElementById('htmx-form').innerHTML=''">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -1,88 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>TopTrumps - Dinosaurs</title>
|
||||
<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>
|
||||
<style>
|
||||
/* Theme variables */
|
||||
:root{
|
||||
--bg:#ffffff;
|
||||
--text:#0a0a0a;
|
||||
--muted:#4a4a4a;
|
||||
--card-bg:#ffffff;
|
||||
--accent:#00d1b2;
|
||||
}
|
||||
[data-theme="dark"]{
|
||||
--bg:#0b0f13;
|
||||
--text:#e6eef3;
|
||||
--muted:#9aa8b0;
|
||||
--card-bg:#0f1620;
|
||||
--accent:#79ffd4;
|
||||
}
|
||||
|
||||
body{background:var(--bg); color:var(--text)}
|
||||
.app-container{padding:1.5rem}
|
||||
.card-item{min-height:140px; background:var(--card-bg); color:var(--text)}
|
||||
.highlight-new{animation: highlight 1.6s ease}
|
||||
@keyframes highlight{0%{background:lavenderblush}100%{background:transparent}}
|
||||
|
||||
/* Theme toggle */
|
||||
#theme-toggle{margin-left:0.5rem}
|
||||
/* 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 .hero-image{height:180px;background-size:cover;background-position:center}
|
||||
.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 .stat-row{display:flex;align-items:center;gap:0.75rem;padding:0.25rem 0}
|
||||
.toptrump-card .stat-label{width:90px;font-weight:600}
|
||||
.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 .fact-list{margin-top:0.5rem;padding-left:1rem}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<section class="section">
|
||||
<div class="container app-container">
|
||||
<div class="level">
|
||||
<div class="level-left">
|
||||
<h1 class="title">TopTrumps — Dinosaurs</h1>
|
||||
</div>
|
||||
<div class="level-right">
|
||||
<div class="level-item">
|
||||
<button id="theme-toggle" class="button is-small" aria-pressed="false">Toggle theme</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="messages"></div>
|
||||
{% block content %}{% endblock %}
|
||||
<div id="modal-root"></div>
|
||||
</div>
|
||||
</section>
|
||||
</body>
|
||||
<script>
|
||||
(function(){
|
||||
const root = document.documentElement;
|
||||
const toggle = document.getElementById('theme-toggle');
|
||||
function applyTheme(t){
|
||||
if(t==='dark') root.setAttribute('data-theme','dark');
|
||||
else root.removeAttribute('data-theme');
|
||||
if(toggle) toggle.setAttribute('aria-pressed', t==='dark');
|
||||
}
|
||||
// initial: localStorage -> prefers-color-scheme -> light
|
||||
const stored = localStorage.getItem('toptrumps-theme');
|
||||
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
applyTheme(stored || (prefersDark ? 'dark' : 'light'));
|
||||
if(toggle){
|
||||
toggle.addEventListener('click', ()=>{
|
||||
const current = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
|
||||
const next = current === 'dark' ? 'light' : 'dark';
|
||||
applyTheme(next);
|
||||
localStorage.setItem('toptrumps-theme', next);
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</html>
|
||||
@@ -1,21 +0,0 @@
|
||||
{% extends 'cards/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="level">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
<button class="button is-primary" hx-get="{% url 'cards:create' %}" hx-target="#htmx-form" hx-swap="innerHTML">Add Dinosaur</button>
|
||||
</div>
|
||||
<div class="level-item">
|
||||
<a class="button is-light" href="{% url 'cards:backgrounds' %}">Manage backgrounds</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="htmx-form"></div>
|
||||
|
||||
<div id="cards-list">
|
||||
{% include 'cards/_cards_list.html' %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -16,11 +16,15 @@ Including another URLconf
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
from django.views.generic import RedirectView
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
|
||||
urlpatterns = [
|
||||
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')),
|
||||
]
|
||||
|
||||
|
||||