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.""" """Convert a 0-100 percent into a 0-5 star score in 0.5 increments."""
if percent is None: if percent is None:
return 0.0 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 # 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): def _stars_string(self, percent):
"""Return a simple star string like '★★★★½' and trailing empty stars ''.""" """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> <strong>{{ bg.title }}</strong>
<div class="is-size-7">Uploaded {{ bg.uploaded_at }}</div> <div class="is-size-7">Uploaded {{ bg.uploaded_at }}</div>
<div class="buttons" style="margin-top:0.5rem"> <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 %} {% 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> <button class="button is-small" hx-post="{% url 'cards:background_activate' bg.pk %}" hx-swap="outerHTML" hx-target="#backgrounds-list">Set active</button>
{% else %} {% else %}
+80 -10
View File
@@ -1,30 +1,100 @@
{% comment %} Preview fragment for pasted bulk import data. {% endcomment %} {% comment %} Preview fragment for pasted bulk import data. {% endcomment %}
<div class="box"> <div class="box">
<h4 class="title is-6">Parsed suggestions</h4> <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"> <form hx-post="{% url 'cards:import_apply_bulk' %}" hx-target="#import-preview" hx-swap="innerHTML">
{% csrf_token %} {% csrf_token %}
<textarea name="data" style="display:none">{{ suggestions_json }}</textarea> <textarea name="data" style="display:none">{{ suggestions_json }}</textarea>
<table class="table is-fullwidth is-striped"> <table class="table is-fullwidth is-striped">
<thead> <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> </thead>
<tbody> <tbody>
{% for s in suggestions %} {% for item in preview_items %}
{% with s=item.suggestion i=item.index %}
{% if item.diffs %}
{% for field in item.diffs %}
<tr> <tr>
<td>{% if s.pk %}pk:{{ s.pk }}{% elif s.name %}name{% else %}{% endif %}</td> <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>{{ s.name|default:"(no name)" }}{% if s.pk %} (pk: {{ s.pk }}){% endif %}</td>
<td>{{ s.speed_kmh }}</td> <td>{{ field }}</td>
<td>{{ s.weight_kg }}</td> <td>
<td>{{ s.height_m }}</td> {% if item.existing %}
<td>{{ s.intelligence_score }}</td> {% if field == 'facts' %}
<td>{% if s.facts %}{% if s.facts|length > 0 %}{{ s.facts|join:"; " }}{% else %}{{ s.facts }}{% endif %}{% endif %}</td> {% 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> </tr>
{% endfor %} {% 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> </tbody>
</table> </table>
<div style="margin-top:0.5rem"> <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> <button class="button" type="button" onclick="document.getElementById('import-preview').innerHTML=''">Cancel</button>
</div> </div>
</form> </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 class="stat-value" style="margin-left:8px;font-weight:600">
<div>{{ card.speed_display }}</div> <div>{{ card.speed_display }}</div>
<div class="is-size-7" style="font-weight:500; display:flex; align-items:center; gap:8px;"> <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> <span style="margin-left:6px">(orig: {{ card.speed_orig }})</span>
</div> </div>
</div> </div>
@@ -44,7 +44,7 @@
<div class="stat-value" style="margin-left:8px;font-weight:600"> <div class="stat-value" style="margin-left:8px;font-weight:600">
<div>{{ card.weight_display }}</div> <div>{{ card.weight_display }}</div>
<div class="is-size-7" style="font-weight:500; display:flex; align-items:center; gap:8px;"> <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> <span style="margin-left:6px">(orig: {{ card.weight_orig }})</span>
</div> </div>
</div> </div>
@@ -53,15 +53,15 @@
<div class="stat-label">Intelligence <small class="is-size-7">(score)</small></div> <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 class="stat-value" style="margin-left:8px;font-weight:600">
<div style="display:flex;align-items:center;gap:8px"> <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 class="is-size-7" style="font-weight:500">(orig: {{ card.intelligence_orig }})</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="buttons" style="margin-top:0.6rem"> <div class="buttons" style="margin-top:0.6rem">
<button class="button is-small is-info" hx-get="{% url 'cards:edit' card.pk %}" hx-target="#htmx-form" hx-swap="innerHTML">Edit</button> <button class="button is-small is-info" hx-get="{% url 'cards:edit' card.pk %}" hx-target="#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">Preview</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> <button class="button is-small is-danger" hx-post="{% url 'cards:delete' card.pk %}" hx-confirm="Delete this card?" hx-target="#card-{{ card.pk }}" hx-swap="outerHTML">Delete</button>
</div> </div>
+15 -7
View File
@@ -1,7 +1,7 @@
{# HTMX modal fragment for previewing a card #} {# HTMX modal fragment for previewing a card #}
{# HTMX modal fragment for previewing a card in Top Trumps style #} {# HTMX modal fragment for previewing a card in Top Trumps style #}
<div class="modal is-active" id="card-preview-modal"> <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 class="modal-content">
<div style="max-width:920px; margin:0 auto;"> <div style="max-width:920px; margin:0 auto;">
<div style="display:flex; justify-content:center;"> <div style="display:flex; justify-content:center;">
@@ -14,7 +14,15 @@
</div> </div>
<div class="title-band">{{ card.name }}</div> <div class="title-band">{{ card.name }}</div>
<div class="card-body"> <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> <div style="margin-top:0.5rem">{{ card.description|linebreaksbr }}</div>
{% if card.facts_list %} {% if card.facts_list %}
@@ -33,20 +41,20 @@
<div class="stat-label">Speed <small class="is-size-7">(km/h)</small></div> <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 class="stat-value" style="margin-left:8px;font-weight:700">
<div>{{ card.speed_display }}</div> <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> </div>
<div class="stat-row"> <div class="stat-row">
<div class="stat-label">Weight <small class="is-size-7">(kg)</small></div> <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 class="stat-value" style="margin-left:8px;font-weight:700">
<div>{{ card.weight_display }}</div> <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> </div>
<div class="stat-row"> <div class="stat-row">
<div class="stat-label">Intelligence <small class="is-size-7">(score)</small></div> <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 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 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 class="is-size-7" style="font-weight:500">(orig: {{ card.intelligence_orig }})</div></div>
</div> </div>
</div> </div>
@@ -54,7 +62,7 @@
<div class="stat-label">Height <small class="is-size-7">(m)</small></div> <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 class="stat-value" style="margin-left:8px;font-weight:700">
<div>{{ card.height_display }}</div> <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> </div>
</div> </div>
@@ -63,5 +71,5 @@
</div> </div>
</div> </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> </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>
+1 -1
View File
@@ -193,7 +193,7 @@
<button class="button is-primary" type="submit">Save</button> <button class="button is-primary" type="submit">Save</button>
</div> </div>
<div class="control"> <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>
</div> </div>
</form> </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"> <div class="level-item">
<a class="button is-light" href="{% url 'cards:list' %}">Back to cards</a> <a class="button is-light" href="{% url 'cards:list' %}">Back to cards</a>
</div> </div>
<div class="level-item">
<a class="button is-link" href="{% url 'cards:backgrounds_download_all' %}">Download all backgrounds</a>
</div>
</div> </div>
</div> </div>
+132 -3
View File
@@ -35,9 +35,44 @@
#theme-toggle{margin-left:0.5rem} #theme-toggle{margin-left:0.5rem}
/* Top Trumps card styles */ /* Top Trumps card styles */
.toptrump-card{max-width:320px;border-radius:10px;overflow:hidden;background:var(--card-bg);color:var(--text);box-shadow:0 6px 18px rgba(0,0,0,0.12);border:1px solid rgba(0,0,0,0.06)} .toptrump-card{max-width:320px;border-radius:10px;overflow:hidden;background:var(--card-bg);color:var(--text);box-shadow:0 6px 18px rgba(0,0,0,0.12);border:1px solid rgba(0,0,0,0.06)}
/* 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} .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 */ /* Hero image: make the dinosaur image larger, cover the banner and remove shadow
.toptrump-card .hero-image img.card-hero-img{ 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; position:absolute;
left:50%; left:50%;
top: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-bar{flex:1;height:12px;background:rgba(255,255,255,0.06);border-radius:8px;overflow:hidden}
.toptrump-card .stat-fill{height:100%;background:linear-gradient(90deg,var(--accent),#ffdd57);} .toptrump-card .stat-fill{height:100%;background:linear-gradient(90deg,var(--accent),#ffdd57);}
.toptrump-card .fact-list{margin-top:0.5rem;padding-left:1rem} .toptrump-card .fact-list{margin-top:0.5rem;padding-left:1rem}
/* 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> </style>
{% include 'cards/_card_hero_styles.html' %}
</head> </head>
<body> <body>
<section class="section"> <section class="section">
@@ -95,7 +141,7 @@
</div> </div>
<div id="messages"></div> <div id="messages"></div>
{% block content %}{% endblock %} {% block content %}{% endblock %}
<div id="modal-root"></div> <div id="modal-root">{% if modal_fragment %}{{ modal_fragment|safe }}{% endif %}</div>
</div> </div>
</section> </section>
</body> </body>
@@ -147,4 +193,87 @@
}); });
})(); })();
</script> </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> </html>
+3 -3
View File
@@ -1,8 +1,8 @@
{% extends "cards/base.html" %} {% extends "cards/base.html" %}
{% block content %} {% block content %}
<section class="section"> <section class="section">
<h2 class="title is-4">Cards overview — bulk edit</h2> <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> <p class="subtitle is-6">Edit multiple cards inline and save changes in one go.</p>
<style> <style>
@@ -151,5 +151,5 @@
</div> </div>
</div> </div>
</form> </form>
</section> </section>
{% endblock %} {% endblock %}
+1 -1
View File
@@ -12,7 +12,7 @@
</div> </div>
</div> </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"> <div class="field">
<label class="label">Export (JSON)</label> <label class="label">Export (JSON)</label>
+9 -1
View File
@@ -4,7 +4,7 @@
<div class="level"> <div class="level">
<div class="level-left"> <div class="level-left">
<div class="level-item"> <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>
<div class="level-item"> <div class="level-item">
<a class="button is-light" href="{% url 'cards:backgrounds' %}">Manage backgrounds</a> <a class="button is-light" href="{% url 'cards:backgrounds' %}">Manage backgrounds</a>
@@ -15,8 +15,16 @@
<div class="level-item"> <div class="level-item">
<a class="button is-light" href="{% url 'cards:export_table' %}">Export table</a> <a class="button is-light" href="{% url 'cards:export_table' %}">Export table</a>
</div> </div>
<div class="level-item">
<a class="button is-light" href="{% url 'cards:print' %}">Print cards</a>
</div> </div>
</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> <div id="htmx-form"></div>
+1 -1
View File
@@ -2,6 +2,6 @@
{% block content %} {% block content %}
<div class="container"> <div class="container">
{% include 'cards/_card_preview.html' %} {% include fragment_template %}
</div> </div>
{% endblock %} {% 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>/edit/', views.card_edit, name='edit'),
path('<int:pk>/delete/', views.card_delete, name='delete'), path('<int:pk>/delete/', views.card_delete, name='delete'),
path('preview/<int:pk>/', views.preview_card, name='preview'), 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/', views.background_list, name='backgrounds'),
path('backgrounds/create/', views.background_create, name='background_create'), 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>/activate/', views.background_activate, name='background_activate'),
path('backgrounds/<int:pk>/delete/', views.background_delete, name='background_delete'), path('backgrounds/<int:pk>/delete/', views.background_delete, name='background_delete'),
path('overview/', views.cards_overview, name='overview'), path('overview/', views.cards_overview, name='overview'),
+468 -29
View File
@@ -1,7 +1,12 @@
from django.shortcuts import render, get_object_or_404, redirect from django.shortcuts import render, get_object_or_404, redirect
from django.http import HttpResponse, HttpResponseBadRequest from django.http import HttpResponse, HttpResponseBadRequest
from django.template.loader import render_to_string from django.template.loader import render_to_string
import io
import zipfile
import os import os
import json
import csv
from io import StringIO
from .models import Dinosaur, BackgroundImage from .models import Dinosaur, BackgroundImage
from .forms import DinosaurBulkFormSet from .forms import DinosaurBulkFormSet
from .forms import DinosaurForm from .forms import DinosaurForm
@@ -62,6 +67,9 @@ def card_list(request):
'current_order': order, 'current_order': order,
} }
# Total (or filtered) card count for UI summary
context['card_count'] = qs.count()
if is_htmx(request): if is_htmx(request):
return render(request, 'cards/_cards_list.html', context) return render(request, 'cards/_cards_list.html', context)
return render(request, 'cards/list.html', context) return render(request, 'cards/list.html', context)
@@ -86,20 +94,22 @@ def card_create(request):
card.save() card.save()
if is_htmx(request): if is_htmx(request):
card_html = render_to_string('cards/_card_item.html', {'card': card, 'background': background}, request=request) card_html = render_to_string('cards/_card_item.html', {'card': card, 'background': background}, request=request)
# Also return an out-of-band fragment to replace/clear the form container # Clear the modal via out-of-band so the modal-root is emptied
form_html = render_to_string('cards/_form.html', {'form': DinosaurForm(), 'background': background, 'backgrounds': backgrounds}, request=request) oob = '<div id="modal-root" hx-swap-oob="true"></div>'
oob = f'<div id="htmx-form" hx-swap-oob="true">{form_html}</div>'
return HttpResponse(card_html + oob) return HttpResponse(card_html + oob)
return redirect('cards:list') return redirect('cards:list')
else: else:
if is_htmx(request): if is_htmx(request):
# Return the form as an out-of-band fragment so it updates the form container # Return the form wrapped in a modal fragment so it updates the modal-root
form_html = render_to_string('cards/_form.html', {'form': form, 'background': background, 'backgrounds': backgrounds}, request=request) form_html = render_to_string('cards/_form_modal.html', {'form': form, 'background': background, 'backgrounds': backgrounds}, request=request)
oob = f'<div id="htmx-form" hx-swap-oob="true">{form_html}</div>' return HttpResponse(form_html)
return HttpResponse(oob)
else: else:
form = DinosaurForm() 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}) return render(request, 'cards/_form.html', {'form': form, 'background': background, 'backgrounds': backgrounds})
@@ -121,18 +131,19 @@ def card_edit(request, pk):
card.save() card.save()
if is_htmx(request): if is_htmx(request):
card_html = render_to_string('cards/_card_item.html', {'card': card, 'background': background}, request=request) card_html = render_to_string('cards/_card_item.html', {'card': card, 'background': background}, request=request)
# Clear the form container via OOB so the edit form disappears after successful save # Clear the modal via OOB so the modal-root is emptied
oob = '<div id="htmx-form" hx-swap-oob="true"></div>' oob = '<div id="modal-root" hx-swap-oob="true"></div>'
return HttpResponse(card_html + oob) return HttpResponse(card_html + oob)
return redirect('cards:list') return redirect('cards:list')
else: else:
if is_htmx(request): if is_htmx(request):
form_html = render_to_string('cards/_form.html', {'form': form, 'card': card, 'background': background, 'backgrounds': backgrounds}, request=request) form_html = render_to_string('cards/_form_modal.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(form_html)
return HttpResponse(oob)
else: else:
form = DinosaurForm(instance=card) 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}) 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}) 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): def background_create(request):
if request.method != 'POST': if request.method != 'POST':
return HttpResponseBadRequest('Only POST allowed') return HttpResponseBadRequest('Only POST allowed')
@@ -207,13 +247,89 @@ def background_delete(request, pk):
return redirect('cards:backgrounds') 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): def preview_card(request, pk):
"""Render a full preview of a card. HTMX loads the modal fragment into #modal-root.""" """Render a full preview of a card. HTMX loads the modal fragment into #modal-root."""
card = get_object_or_404(Dinosaur, pk=pk) card = get_object_or_404(Dinosaur, pk=pk)
background = BackgroundImage.get_active() if BackgroundImage.objects.exists() else None background = BackgroundImage.get_active() if BackgroundImage.objects.exists() else None
# 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): if is_htmx(request):
return render(request, 'cards/_card_preview.html', {'card': card, 'background': background}) return render(request, template_name, context)
return render(request, 'cards/preview.html', {'card': card, 'background': background}) # 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): def cards_overview(request):
@@ -222,7 +338,8 @@ def cards_overview(request):
qs = Dinosaur.objects.order_by('name') qs = Dinosaur.objects.order_by('name')
# Use a modelformset so the template renders the management form # Use a modelformset so the template renders the management form
formset = DinosaurBulkFormSet(queryset=qs) 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): def cards_bulk_update(request):
@@ -243,7 +360,8 @@ def cards_bulk_update(request):
return redirect('cards:overview') return redirect('cards:overview')
# If invalid, re-render the overview with errors # If invalid, re-render the overview with errors
backgrounds = BackgroundImage.objects.all() 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): def cards_bulk_create_images(request):
@@ -279,6 +397,10 @@ def export_table(request):
rows.append({ rows.append({
'pk': d.pk, 'pk': d.pk,
'name': d.name, '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, 'speed_kmh': d.speed,
'weight_kg': d.weight, 'weight_kg': d.weight,
'height_m': float(d.height), 'height_m': float(d.height),
@@ -290,6 +412,175 @@ def export_table(request):
return render(request, 'cards/export_table.html', {'json_text': json_text}) 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): def _parse_pasted_input(text):
"""Try to parse pasted LLM output into a list of suggestion dicts. """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)} entry = {k.lower(): v for k, v in item.items()} if isinstance(item, dict) else {'raw': str(item)}
s = { s = {
'pk': entry.get('pk') or entry.get('id'), 'pk': entry.get('pk') or entry.get('id'),
'era': entry.get('era'),
'diet': entry.get('diet'),
'name': entry.get('name'), 'name': entry.get('name'),
'speed_kmh': entry.get('speed_kmh') or entry.get('speed') or entry.get('speed_km/h'), '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'), '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 s['facts'] = parts
suggestions.append(s) 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) 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): def apply_bulk_import(request):
@@ -388,7 +778,13 @@ def apply_bulk_import(request):
return HttpResponseBadRequest('Failed to parse JSON data') return HttpResponseBadRequest('Failed to parse JSON data')
applied = 0 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 name = item.get('name') if isinstance(item, dict) else None
pk = item.get('pk') if isinstance(item, dict) else None pk = item.get('pk') if isinstance(item, dict) else None
d = None d = None
@@ -402,34 +798,77 @@ def apply_bulk_import(request):
d = Dinosaur.objects.get(name__iexact=name) d = Dinosaur.objects.get(name__iexact=name)
except Dinosaur.DoesNotExist: except Dinosaur.DoesNotExist:
d = None 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: if not d:
continue continue
# apply numeric fields if present
def _to_float(v): # determine whether the user checked to apply this field for this item
try: def _should_apply(field_name):
return float(v) return bool(request.POST.get(f'apply-{idx}-{field_name}'))
except Exception:
return None
speed = _to_float(item.get('speed_kmh')) speed = _to_float(item.get('speed_kmh'))
weight = _to_float(item.get('weight_kg')) weight = _to_float(item.get('weight_kg'))
height = _to_float(item.get('height_m')) height = _to_float(item.get('height_m'))
intelligence = _to_float(item.get('intelligence_score') or item.get('intelligence')) 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)) 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)) 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))) 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: if 0 <= intelligence <= 5:
d.intelligence = int(round((intelligence / 5.0) * 100)) d.intelligence = int(round((intelligence / 5.0) * 100))
else: else:
d.intelligence = int(round(max(0, min(100, intelligence)))) d.intelligence = int(round(max(0, min(100, intelligence))))
# Name
if item.get('name') and _should_apply('name'):
d.name = item.get('name')
# Ensure new records have a name (fallback)
if getattr(d, 'name', None) in (None, ''):
d.name = item.get('name') or f'Imported Dinosaur {idx + 1}'
# Era and diet: accept code or display label
if item.get('era') and _should_apply('era'):
era_val = item.get('era')
if isinstance(era_val, str):
mapped = None
for code, label in Dinosaur.ERA_CHOICES:
if era_val.lower() == code.lower() or era_val.lower() == label.lower():
mapped = code
break
if mapped:
d.era = mapped
else:
d.era = era_val
if item.get('diet') and _should_apply('diet'):
diet_val = item.get('diet')
if isinstance(diet_val, str):
mapped = None
for code, label in Dinosaur.DIET_CHOICES:
if diet_val.lower() == code.lower() or diet_val.lower() == label.lower():
mapped = code
break
if mapped:
d.diet = mapped
else:
d.diet = diet_val
# Facts
if _should_apply('facts'):
facts = item.get('facts') facts = item.get('facts')
if isinstance(facts, list): if isinstance(facts, list):
d.facts = '\n'.join([str(x).strip() for x in facts if str(x).strip()]) d.facts = '\n'.join([str(x).strip() for x in facts if str(x).strip()])
elif isinstance(facts, str): elif isinstance(facts, str):
d.facts = '\n'.join([l.strip() for l in facts.splitlines() if l.strip()]) d.facts = '\n'.join([l.strip() for l in facts.splitlines() if l.strip()])
d.save() d.save()
applied += 1 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