Compare commits

...
59 Commits
Author SHA1 Message Date
Ross c49ce3bc58 . 2025-12-07 09:06:37 +00:00
Ross 6bbc281a9b . 2025-12-06 23:11:17 +00:00
Ross e541e4cc31 . 2025-12-06 22:54:55 +00:00
Ross 2dc1631fd2 . 2025-12-06 22:44:27 +00:00
Ross b1364a6eea . 2025-12-06 22:41:44 +00:00
Ross e5d1f1e83f . 2025-12-06 22:32:54 +00:00
Ross 7661ad8f00 . 2025-12-06 22:19:01 +00:00
Ross d9c64f34b1 . 2025-12-06 22:16:15 +00:00
Ross 626c44796b . 2025-12-06 22:13:56 +00:00
Ross 3e748f6080 . 2025-12-06 22:06:48 +00:00
Ross aa4baae500 . 2025-12-06 22:02:31 +00:00
Ross 25b80f5dea . 2025-12-06 21:57:21 +00:00
Ross 1fb573525b . 2025-12-06 21:51:03 +00:00
Ross cb430859e4 . 2025-12-06 21:47:44 +00:00
Ross 83439613bb . 2025-12-06 21:29:26 +00:00
Ross 659efda29f . 2025-12-06 21:26:52 +00:00
Ross b67bfc4545 . 2025-12-06 21:24:18 +00:00
Ross ce401fa633 . 2025-12-06 21:22:13 +00:00
Ross 81397e8437 . 2025-12-06 21:12:51 +00:00
Ross 013063fd61 . 2025-12-06 21:05:23 +00:00
Ross 6479eb7446 . 2025-12-06 21:03:18 +00:00
Ross 0e4e52fce6 . 2025-12-06 20:46:04 +00:00
Ross c30cb45fc3 . 2025-12-06 20:40:09 +00:00
Ross 2663b10ebe . 2025-12-06 20:40:01 +00:00
Ross 7c7c78c4fc . 2025-12-06 20:33:37 +00:00
Ross 688a04f47a . 2025-12-06 20:32:50 +00:00
Ross 9dd761aa41 . 2025-12-06 20:26:46 +00:00
Ross 78c7c1d502 Enhance design-4 layout: adjust hero image size and card dimensions for improved visual consistency 2025-12-06 20:11:25 +00:00
Ross a4de9ff745 . 2025-12-06 20:05:29 +00:00
Ross d262f7cd3b . 2025-12-06 20:00:29 +00:00
Ross 4a2afdc0c7 . 2025-12-06 19:54:00 +00:00
Ross 679af14739 . 2025-12-06 19:24:06 +00:00
Ross 502f875c4d . 2025-12-06 19:22:42 +00:00
Ross 1752d892f5 . 2025-12-06 19:22:00 +00:00
Ross 5a7e7f6ad4 . 2025-12-06 19:19:04 +00:00
Ross 09922d7019 . 2025-12-06 18:47:08 +00:00
Ross f33a7705d1 . 2025-12-06 18:36:14 +00:00
Ross 0e81fe2235 . 2025-12-06 18:31:01 +00:00
Ross 3566666cc2 . 2025-12-05 22:49:58 +00:00
Ross 346cfa3f5f remove unused card templates and related files 2025-12-05 22:47:52 +00:00
Ross 0155fa39b0 better dino scaling 2025-12-05 22:44:37 +00:00
Ross 011a5acce7 . 2025-12-05 22:39:46 +00:00
Ross 6e4f41007b . 2025-12-05 22:32:23 +00:00
Ross 322c22f14e . 2025-12-05 22:31:16 +00:00
Ross ceea07d0b3 . 2025-12-05 22:25:29 +00:00
Ross 5de97c5ee0 . 2025-12-05 22:04:09 +00:00
Ross 3b920e819e . 2025-12-05 14:42:25 +00:00
Ross df7aa67ce8 . 2025-12-05 14:09:44 +00:00
Ross 132aac6edd . 2025-12-05 14:02:32 +00:00
Ross 1f155f0d56 . 2025-12-05 13:56:12 +00:00
Ross c71c229ea8 . 2025-12-05 13:53:08 +00:00
Ross 94fd98a612 . 2025-12-05 13:47:02 +00:00
Ross e5fa66d622 . 2025-12-05 13:36:18 +00:00
Ross bb8298b18c p 2025-12-05 13:33:34 +00:00
Ross 78240090f1 . 2025-12-05 12:08:08 +00:00
Ross d55cd821b5 . 2025-12-05 11:35:22 +00:00
Ross 4ce91f6390 . 2025-12-05 11:06:21 +00:00
Ross 09be93e8b6 Refactor code structure for improved readability and maintainability 2025-12-05 10:52:27 +00:00
Ross bf9e90d754 . 2025-12-05 10:46:16 +00:00
80 changed files with 2951 additions and 328 deletions
+25 -5
View File
@@ -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)
+91
View File
@@ -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 = [
]
+191 -5
View File
@@ -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>
+3 -2
View File
@@ -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 %}&nbsp;{% endif %}</td>
<td>{% if item.matched %}pk:{{ item.matched.pk }}{% elif s.name %}name{% else %}&mdash;{% 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 %}&mdash;{% 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>
+49 -9
View File
@@ -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 }}
&nbsp;&mdash;&nbsp;
<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>
+41 -9
View File
@@ -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>
+84 -6
View File
@@ -3,7 +3,7 @@
<form method="post" enctype="multipart/form-data" action="{% url 'cards:edit' card.pk %}" hx-post="{% url 'cards:edit' card.pk %}" hx-target="#card-{{ card.pk }}" hx-swap="outerHTML">
{% 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>
+10
View File
@@ -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>
+193 -2
View File
@@ -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>
+155
View File
@@ -0,0 +1,155 @@
{% extends "cards/base.html" %}
{% block content %}
<section class="section">
<h2 class="title is-4">Cards overview — bulk edit{% 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 %}
+46
View File
@@ -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 %}
+67 -1
View File
@@ -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>
+1 -1
View File
@@ -2,6 +2,6 @@
{% block content %}
<div class="container">
{% include 'cards/_card_preview.html' %}
{% include fragment_template %}
</div>
{% endblock %}
+208
View File
@@ -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 }} &mdash; {{ 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 %}
+172
View File
@@ -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>
+11
View File
@@ -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'),
]
+756 -23
View File
@@ -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')
BIN
View File
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+1
View File
@@ -1,2 +1,3 @@
django
Pillow
-30
View File
@@ -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>
-10
View File
@@ -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>
-115
View File
@@ -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>
-88
View File
@@ -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>
-21
View File
@@ -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 %}
+4
View File
@@ -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')),
]