Compare commits

...
38 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
33 changed files with 1815 additions and 178 deletions
+10 -2
View File
@@ -102,9 +102,17 @@ class Dinosaur(models.Model):
"""Convert a 0-100 percent into a 0-5 star score in 0.5 increments."""
if percent is None:
return 0.0
val = float(percent) * 0.05 # scale 0-100 -> 0-5
pct = float(percent)
# scale 0-100 -> 0-5
val = pct * 0.05
# round to nearest 0.5
return round(val * 2) / 2.0
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 ''."""
@@ -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>
@@ -8,6 +8,7 @@
<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 %}
+83 -13
View File
@@ -1,30 +1,100 @@
{% comment %} Preview fragment for pasted bulk import data. {% endcomment %}
<div class="box">
<h4 class="title is-6">Parsed suggestions</h4>
{% if suggestions %}
{% 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>Match</th><th>Name / PK</th><th>Speed</th><th>Weight</th><th>Height</th><th>Intelligence</th><th>Facts</th></tr>
<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 s in suggestions %}
<tr>
<td>{% if s.pk %}pk:{{ s.pk }}{% elif s.name %}name{% else %}—{% endif %}</td>
<td>{{ s.name|default:"(no name)" }}{% if s.pk %} (pk: {{ s.pk }}){% endif %}</td>
<td>{{ s.speed_kmh }}</td>
<td>{{ s.weight_kg }}</td>
<td>{{ s.height_m }}</td>
<td>{{ s.intelligence_score }}</td>
<td>{% if s.facts %}{% if s.facts|length > 0 %}{{ s.facts|join:"; " }}{% else %}{{ s.facts }}{% endif %}{% endif %}</td>
</tr>
{% 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 all suggestions</button>
<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>
@@ -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>
+5 -5
View File
@@ -34,7 +34,7 @@
<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 %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}</div>
<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>
@@ -44,7 +44,7 @@
<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 %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}</div>
<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>
@@ -53,15 +53,15 @@
<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 %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}</div></div>
<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-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>
+16 -8
View File
@@ -1,7 +1,7 @@
{# 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;">
@@ -14,7 +14,15 @@
</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 %}
@@ -33,28 +41,28 @@
<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 %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}</div> <span style="margin-left:6px">(orig: {{ card.speed_orig }})</span></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 %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}</div> <span style="margin-left:6px">(orig: {{ card.weight_orig }})</span></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 %}<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 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 %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}</div> <span style="margin-left:6px">(orig: {{ card.height_orig }})</span></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>
@@ -63,5 +71,5 @@
</div>
</div>
</div>
<button class="modal-close is-large" aria-label="close" onclick="document.getElementById('modal-root').innerHTML=''"></button>
<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>
+5 -5
View File
@@ -121,11 +121,11 @@
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;
}
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 */ }
}
} catch(e){ /* ignore */ }
});
// Make sure clicking the visual label triggers the input if not already clickable
const outerLabel = fi.closest('.file').querySelector('.file-label');
@@ -193,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>
@@ -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>
+132 -3
View File
@@ -35,9 +35,44 @@
#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)}
/* 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 */
.toptrump-card .hero-image img.card-hero-img{
/* 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%;
@@ -78,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">
@@ -95,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>
@@ -147,4 +193,87 @@
});
})();
</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>
+105 -105
View File
@@ -1,8 +1,8 @@
{% extends "cards/base.html" %}
{% block content %}
<section class="section">
<h2 class="title is-4">Cards overview — bulk edit</h2>
<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>
@@ -39,108 +39,108 @@
{% 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>
<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>
@@ -151,5 +151,5 @@
</div>
</div>
</form>
</section>
</section>
{% endblock %}
+1 -1
View File
@@ -12,7 +12,7 @@
</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>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>
<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>
+9 -1
View File
@@ -4,7 +4,7 @@
<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>
@@ -15,8 +15,16 @@
<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>
+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>
+4
View File
@@ -9,8 +9,12 @@ 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'),
+473 -34
View File
@@ -1,7 +1,12 @@
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
@@ -62,6 +67,9 @@ def card_list(request):
'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', context)
return render(request, 'cards/list.html', context)
@@ -86,20 +94,22 @@ def card_create(request):
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(), 'background': background, 'backgrounds': backgrounds}, 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, 'background': background, 'backgrounds': backgrounds}, 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()
# 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})
@@ -121,18 +131,19 @@ def card_edit(request, pk):
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, 'background': background, 'backgrounds': backgrounds}, 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)
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})
@@ -158,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')
@@ -207,13 +247,89 @@ def background_delete(request, pk):
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):
@@ -222,7 +338,8 @@ def cards_overview(request):
qs = Dinosaur.objects.order_by('name')
# Use a modelformset so the template renders the management form
formset = DinosaurBulkFormSet(queryset=qs)
return render(request, 'cards/bulk_edit.html', {'formset': formset, 'backgrounds': backgrounds})
card_count = qs.count()
return render(request, 'cards/bulk_edit.html', {'formset': formset, 'backgrounds': backgrounds, 'card_count': card_count})
def cards_bulk_update(request):
@@ -243,7 +360,8 @@ def cards_bulk_update(request):
return redirect('cards:overview')
# If invalid, re-render the overview with errors
backgrounds = BackgroundImage.objects.all()
return render(request, 'cards/bulk_edit.html', {'formset': formset, 'backgrounds': backgrounds})
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):
@@ -279,6 +397,10 @@ def export_table(request):
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),
@@ -290,6 +412,175 @@ def export_table(request):
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.
@@ -353,6 +644,8 @@ def import_from_paste(request):
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'),
@@ -369,9 +662,106 @@ def import_from_paste(request):
s['facts'] = parts
suggestions.append(s)
# provide a JSON-serialized version of suggestions so the preview form can post it
# 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', {'suggestions': suggestions, 'suggestions_json': suggestions_json})
return render(request, 'cards/_bulk_import_preview.html', {'preview_items': preview_items, 'suggestions_json': suggestions_json})
def apply_bulk_import(request):
@@ -388,7 +778,13 @@ def apply_bulk_import(request):
return HttpResponseBadRequest('Failed to parse JSON data')
applied = 0
for item in parsed:
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
@@ -402,34 +798,77 @@ def apply_bulk_import(request):
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
# apply numeric fields if present
def _to_float(v):
try:
return float(v)
except Exception:
return None
# 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:
if speed is not None and _should_apply('speed_kmh'):
d.speed = int(round(speed))
if weight is not None:
if weight is not None and _should_apply('weight_kg'):
d.weight = int(round(weight))
if height is not None:
if height is not None and _should_apply('height_m'):
d.height = Decimal(str(round(height, 1)))
if intelligence is not None:
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))))
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()])
# 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
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