Compare commits

...
8 Commits
Author SHA1 Message Date
Ross 3566666cc2 . 2025-12-05 22:49:58 +00:00
Ross 346cfa3f5f remove unused card templates and related files 2025-12-05 22:47:52 +00:00
Ross 0155fa39b0 better dino scaling 2025-12-05 22:44:37 +00:00
Ross 011a5acce7 . 2025-12-05 22:39:46 +00:00
Ross 6e4f41007b . 2025-12-05 22:32:23 +00:00
Ross 322c22f14e . 2025-12-05 22:31:16 +00:00
Ross ceea07d0b3 . 2025-12-05 22:25:29 +00:00
Ross 5de97c5ee0 . 2025-12-05 22:04:09 +00:00
20 changed files with 486 additions and 606 deletions
+91
View File
@@ -0,0 +1,91 @@
import os
import json
import time
from typing import Optional, Dict, Any
try:
import openai
except Exception:
openai = None
OPENAI_API_KEY = os.environ.get('OPENAI_API_KEY')
DEFAULT_MODEL = os.environ.get('CARDS_LLM_MODEL', 'gpt-4o-mini')
PROMPT_TEMPLATE = (
"""
You are a JSON generator. Given a dinosaur name, return EXACTLY one JSON object matching the schema below.
Only return JSON (no explanatory text).
Schema:
{
"name": string,
"speed_kmh": number|null, // numeric speed in km/h
"weight_kg": number|null, // numeric weight in kg
"height_m": number|null, // numeric height in meters
"intelligence_score": integer|null, // 1-5 scale or null
"facts": [string,...],
"sources": [{"title":string,"url":string},...]
}
Rules:
- Use units: km/h, kg, m.
- If you cannot find a reliable number, use null.
- Provide up to 5 short facts.
- Include at least one source object with a URL when available.
Name: {name}
"""
)
def _ensure_openai():
if openai is None:
raise RuntimeError('openai package not installed; add openai to requirements')
if not OPENAI_API_KEY:
raise RuntimeError('OPENAI_API_KEY not set in environment')
openai.api_key = OPENAI_API_KEY
def _extract_json(text: str) -> Optional[Dict[str, Any]]:
text = text.strip()
# try direct parse
try:
return json.loads(text)
except Exception:
pass
# heuristic: find first { ... } block
start = text.find('{')
end = text.rfind('}')
if start != -1 and end != -1 and end > start:
try:
return json.loads(text[start:end+1])
except Exception:
return None
return None
def fetch_dinosaur_structured(name: str, model: Optional[str] = None, max_retries: int = 2) -> Dict[str, Any]:
"""Call the configured LLM and return parsed JSON per schema.
Raises RuntimeError on misconfiguration or ValueError if parsing fails.
"""
model = model or DEFAULT_MODEL
if openai is None:
raise RuntimeError('openai package not available')
_ensure_openai()
prompt = PROMPT_TEMPLATE.format(name=name)
for attempt in range(max_retries + 1):
resp = openai.ChatCompletion.create(
model=model,
messages=[{"role": "user", "content": prompt}],
temperature=0.0,
max_tokens=600,
)
content = resp['choices'][0]['message']['content'].strip()
data = _extract_json(content)
if data:
return data
time.sleep(1 + attempt)
raise ValueError('Failed to parse LLM response as JSON')
+46
View File
@@ -174,6 +174,52 @@ class Dinosaur(models.Model):
def intelligence_orig(self): def intelligence_orig(self):
return self.intelligence return self.intelligence
@property
def background_image_scale(self):
"""Return a CSS background-size percentage influenced by the
dinosaur's stored height. Uses `height_percent()` to derive a
value in a reasonable range so larger dinosaurs get a larger
background image scale.
The result is a string like '60%'. Keep the value clamped to
avoid extremely small/large sizes.
"""
try:
# Use a combined metric (height + weight) to smooth out extremes
h = float(self.height_percent() or 0)
w = float(self.weight_percent() or 0)
p = (h + w) / 2.0
except Exception:
p = 50.0
# Use much smaller range for subtler effect: largest dinos -> 100%
# (full image), smallest -> 140% (mild zoom). Interpolate linearly.
min_scale = 100
max_scale = 140
scale = int(round(min_scale + (1.0 - (p / 100.0)) * (max_scale - min_scale)))
scale = max(min_scale, min(max_scale, scale))
return f"{scale}%"
@property
def image_scale(self):
"""Return a scale factor (float) for the dinosaur's image size.
Smaller dinosaurs will be scaled down (e.g. 0.6) and larger
dinosaurs closer to full size (1.0). Uses `height_percent()`.
"""
try:
# Combine height and weight for a smoother scale value
h = float(self.height_percent() or 0)
w = float(self.weight_percent() or 0)
p = (h + w) / 2.0
except Exception:
p = 50.0
# Subtle scaling: smallest -> 0.90, largest -> 1.00
min_scale = 0.90
max_scale = 1.00
scale = min_scale + (p / 100.0) * (max_scale - min_scale)
scale = max(min_scale, min(max_scale, scale))
return f"{scale:.2f}"
class BackgroundImage(models.Model): class BackgroundImage(models.Model):
"""Background images used behind card renders. """Background images used behind card renders.
+2 -2
View File
@@ -1,7 +1,7 @@
<div id="backgrounds-list"> <div id="backgrounds-list">
<div class="columns is-multiline"> <div class="columns is-multiline">
{% for bg in backgrounds %} {% for bg in backgrounds %}
<div class="column is-3-desktop is-4-tablet is-6-mobile"> <div class="column is-3-desktop is-4-tablet is-6-mobile" id="background-{{ bg.pk }}">
<article class="box"> <article class="box">
<div style="height:140px; background-image:url('{{ bg.image.url }}'); background-size:cover; background-position:center; border-radius:4px;"></div> <div style="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 style="margin-top:0.5rem">
@@ -13,7 +13,7 @@
{% else %} {% else %}
<span class="tag is-success">Active</span> <span class="tag is-success">Active</span>
{% endif %} {% endif %}
<button class="button is-small is-danger" hx-post="{% url 'cards:background_delete' bg.pk %}" hx-confirm="Delete this background?" hx-swap="none">Delete</button> <button class="button is-small is-danger" hx-post="{% url 'cards:background_delete' bg.pk %}" hx-confirm="Delete this background?" hx-target="#background-{{ bg.pk }}" hx-swap="outerHTML">Delete</button>
</div> </div>
</div> </div>
</article> </article>
@@ -0,0 +1,34 @@
{% comment %} Preview fragment for pasted bulk import data. {% endcomment %}
<div class="box">
<h4 class="title is-6">Parsed suggestions</h4>
{% if suggestions %}
<form hx-post="{% url 'cards:import_apply_bulk' %}" hx-target="#import-preview" hx-swap="innerHTML">
{% csrf_token %}
<textarea name="data" style="display:none">{{ suggestions_json }}</textarea>
<table class="table is-fullwidth is-striped">
<thead>
<tr><th>Match</th><th>Name / PK</th><th>Speed</th><th>Weight</th><th>Height</th><th>Intelligence</th><th>Facts</th></tr>
</thead>
<tbody>
{% for s in suggestions %}
<tr>
<td>{% if s.pk %}pk:{{ s.pk }}{% elif s.name %}name{% else %}—{% endif %}</td>
<td>{{ s.name|default:"(no name)" }}{% if s.pk %} (pk: {{ s.pk }}){% endif %}</td>
<td>{{ s.speed_kmh }}</td>
<td>{{ s.weight_kg }}</td>
<td>{{ s.height_m }}</td>
<td>{{ s.intelligence_score }}</td>
<td>{% if s.facts %}{% if s.facts|length > 0 %}{{ s.facts|join:"; " }}{% else %}{{ s.facts }}{% endif %}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div style="margin-top:0.5rem">
<button class="button is-primary" type="submit">Apply all suggestions</button>
<button class="button" type="button" onclick="document.getElementById('import-preview').innerHTML=''">Cancel</button>
</div>
</form>
{% else %}
<div class="notification is-warning">No suggestions parsed from the pasted data.</div>
{% endif %}
</div>
+25 -14
View File
@@ -1,13 +1,26 @@
<article class="card-item" id="card-{{ card.pk }}"> <article class="card-item" id="card-{{ card.pk }}">
<div class="toptrump-card"> <div class="toptrump-card">
<div class="hero-image" style="background-image: url('{% if card.background and card.background.image %}{{ card.background.image.url }}{% elif background and background.image %}{{ background.image.url }}{% endif %}');"> <div class="hero-image" style="background-image: url('{% if card.background and card.background.image %}{{ card.background.image.url }}{% elif background and background.image %}{{ background.image.url }}{% endif %}'); background-size: {{ card.background_image_scale }}; background-position: center;">
{% if card.image %} {% if card.image %}
<img class="card-hero-img" src="{{ card.image.url }}" alt="{{ card.name }}"> <img class="card-hero-img" src="{{ card.image.url }}" alt="{{ card.name }}" style="transform: translate(-50%,-50%) scale({{ card.image_scale }}); transform-origin: center; transition: transform 200ms ease;" />
{% endif %} {% endif %}
</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-7"><strong>Era:</strong> {{ card.get_era_display }} — <strong>Diet:</strong> {{ card.get_diet_display }}</div> <div class="subtitle is-7">
<strong>Era:</strong> {{ card.get_era_display }}
&nbsp;&mdash;&nbsp;
<strong>Diet:</strong>
{% if card.diet == 'herbivore' %}
<span class="tag is-light is-small" title="Herbivore"><span role="img" aria-label="Herbivore">🌿</span> Herbivore</span>
{% elif card.diet == 'carnivore' %}
<span class="tag is-light is-small" title="Carnivore"><span role="img" aria-label="Carnivore">🥩</span> Carnivore</span>
{% elif card.diet == 'omnivore' %}
<span class="tag is-light is-small" title="Omnivore"><span role="img" aria-label="Omnivore">🍽️</span> Omnivore</span>
{% else %}
{{ card.get_diet_display }}
{% endif %}
</div>
{% if card.facts_list %} {% if card.facts_list %}
<ul class="fact-list is-size-7"> <ul class="fact-list is-size-7">
{% for fact in card.facts_list|slice:":2" %} {% for fact in card.facts_list|slice:":2" %}
@@ -18,41 +31,39 @@
<div style="margin-top:0.5rem"> <div style="margin-top:0.5rem">
<div class="stat-row"> <div class="stat-row">
<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-bar"><div class="stat-fill" style="width:{{ card.speed_percent }}%"></div></div>
<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"> <div class="is-size-7" style="font-weight:500; display:flex; align-items:center; gap:8px;">
{% for t in card.speed_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %} <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> <span style="margin-left:6px">(orig: {{ card.speed_orig }})</span>
</div> </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-bar"><div class="stat-fill" style="width:{{ card.weight_percent }}%"></div></div>
<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"> <div class="is-size-7" style="font-weight:500; display:flex; align-items:center; gap:8px;">
{% for t in card.weight_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %} <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> <span style="margin-left:6px">(orig: {{ card.weight_orig }})</span>
</div> </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-bar"><div class="stat-fill" style="width:{{ card.intelligence_percent }}%"></div></div>
<div class="stat-value" style="margin-left:8px;font-weight:600"> <div class="stat-value" style="margin-left:8px;font-weight:600">
<div class="is-size-7" style="font-weight:600"> <div style="display:flex;align-items:center;gap:8px">
{% for t in card.intelligence_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %} <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>
<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 class="buttons" style="margin-top:0.6rem"> <div class="buttons" style="margin-top:0.6rem">
<button class="button is-small is-info" hx-get="{% url 'cards:edit' card.pk %}" hx-target="#htmx-form" hx-swap="innerHTML">Edit</button> <button class="button is-small is-info" hx-get="{% url 'cards:edit' card.pk %}" hx-target="#htmx-form" hx-swap="innerHTML">Edit</button>
<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">Preview</button>
<button class="button is-small is-danger" hx-post="{% url 'cards:delete' card.pk %}" hx-confirm="Delete this card?" hx-swap="none">Delete</button>
<button class="button is-small is-danger" hx-post="{% url 'cards:delete' card.pk %}" hx-confirm="Delete this card?" hx-target="#card-{{ card.pk }}" hx-swap="outerHTML">Delete</button>
</div> </div>
</div> </div>
</div> </div>
+5 -9
View File
@@ -31,34 +31,30 @@
<div style="margin-top:0.8rem"> <div style="margin-top:0.8rem">
<div class="stat-row"> <div class="stat-row">
<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-bar"><div class="stat-fill" style="width:{{ card.speed_percent }}%"></div></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">{% for t in card.speed_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %} <span style="margin-left:6px">(orig: {{ card.speed_orig }})</span></div> <div 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> </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-bar"><div class="stat-fill" style="width:{{ card.weight_percent }}%"></div></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">{% for t in card.weight_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %} <span style="margin-left:6px">(orig: {{ card.weight_orig }})</span></div> <div 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> </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-bar"><div class="stat-fill" style="width:{{ card.intelligence_percent }}%"></div></div>
<div class="stat-value" style="margin-left:8px;font-weight:700"> <div class="stat-value" style="margin-left:8px;font-weight:700">
<div class="is-size-7" style="font-weight:600">{% for t in card.intelligence_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}</div> <div style="display:flex;align-items:center;gap:8px"><div class="is-size-7" style="font-weight:600"><div class="stars">{% for t in card.intelligence_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}</div></div>
<div class="is-size-7" style="font-weight:500">(orig: {{ card.intelligence_orig }})</div> <div class="is-size-7" style="font-weight:500">(orig: {{ card.intelligence_orig }})</div></div>
</div> </div>
</div> </div>
<div class="stat-row"> <div class="stat-row">
<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-bar"><div class="stat-fill" style="width:{{ card.height_percent }}%"></div></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">{% for t in card.height_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %} <span style="margin-left:6px">(orig: {{ card.height_orig }})</span></div> <div 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> </div>
</div> </div>
</div> </div>
@@ -0,0 +1,49 @@
{% comment %} HTMX fragment showing LLM suggestion and form to accept/apply it. {% endcomment %}
<div id="import-preview" class="box">
{% if error %}
<div class="notification is-danger">Error contacting LLM: {{ error }}</div>
{% else %}
<h4 class="title is-6">Import suggestions for {{ card.name }}</h4>
<form hx-post="{% url 'cards:import_apply' card.pk %}" hx-target="#htmx-form" hx-swap="innerHTML">
<table class="table is-fullwidth">
<tr><th>Field</th><th>Suggested</th></tr>
<tr>
<td>Speed (km/h)</td>
<td><input name="speed_kmh" value="{{ suggestion.speed_kmh|default_if_none:'' }}" class="input" /></td>
</tr>
<tr>
<td>Weight (kg)</td>
<td><input name="weight_kg" value="{{ suggestion.weight_kg|default_if_none:'' }}" class="input" /></td>
</tr>
<tr>
<td>Height (m)</td>
<td><input name="height_m" value="{{ suggestion.height_m|default_if_none:'' }}" class="input" /></td>
</tr>
<tr>
<td>Intelligence (1-5 or 0-100)</td>
<td><input name="intelligence_score" value="{{ suggestion.intelligence_score|default_if_none:'' }}" class="input" /></td>
</tr>
<tr>
<td>Facts</td>
<td>
<textarea name="facts" class="textarea" rows="6">{% if suggestion.facts %}{{ suggestion.facts|join:"\n" }}{% endif %}</textarea>
</td>
</tr>
</table>
{% if suggestion.sources %}
<div class="content">
<strong>Sources:</strong>
<ul>
{% for s in suggestion.sources %}
<li>{% if s.url %}<a href="{{ s.url }}" target="_blank">{{ s.title|default:s.url }}</a>{% else %}{{ s.title }}{% endif %}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<div style="margin-top:0.5rem">
<button class="button is-primary" type="submit">Apply</button>
<button class="button" type="button" onclick="document.getElementById('htmx-form').innerHTML=''">Cancel</button>
</div>
</form>
{% endif %}
</div>
+3
View File
@@ -51,6 +51,9 @@
border-radius:6px; border-radius:6px;
box-shadow:none; box-shadow:none;
} }
/* Consistent star group styling */
.toptrump-card .stars{display:inline-flex; gap:6px; align-items:center; font-size:1rem; margin-left:6px}
.toptrump-card .stars .star{margin:0;}
/* Star rating visuals (app-level) */ /* Star rating visuals (app-level) */
.star { font-size:0.95rem; display:inline-block; line-height:1; margin-left:2px; } .star { font-size:0.95rem; display:inline-block; line-height:1; margin-left:2px; }
.star.full { color:#ffdd57; } .star.full { color:#ffdd57; }
+46
View File
@@ -0,0 +1,46 @@
{% extends 'cards/base.html' %}
{% block content %}
<section class="section">
<div class="level">
<div class="level-left">
<h2 class="title is-4">Export table of all dinosaurs</h2>
</div>
<div class="level-right">
<div class="level-item">
<a class="button is-light" href="{% url 'cards:list' %}">Back to list</a>
</div>
</div>
</div>
<p>Copy the JSON below and paste it into your web LLM interface. Ask the model to return a JSON array of objects with these fields: <code>pk</code> or <code>name</code>, <code>speed_kmh</code>, <code>weight_kg</code>, <code>height_m</code>, <code>intelligence</code>, <code>facts</code> (array or newline-separated string). When you get the model response, paste that JSON into the box below and click <strong>Preview pasted results</strong>.</p>
<div class="field">
<label class="label">Export (JSON)</label>
<div class="control">
<textarea id="export-table-text" class="textarea" rows="10">{{ json_text }}</textarea>
</div>
<div style="margin-top:0.5rem">
<button class="button" onclick="(function(){var t=document.getElementById('export-table-text');t.select();try{navigator.clipboard.writeText(t.value);}catch(e){} })()">Copy JSON</button>
</div>
</div>
<hr />
<h3 class="title is-6">Paste LLM output</h3>
<form method="post" hx-post="{% url 'cards:import_paste' %}" hx-target="#import-preview" hx-swap="innerHTML">
{% csrf_token %}
<div class="field">
<div class="control">
<textarea name="pasted" class="textarea" rows="12" placeholder='Paste the LLM response here (JSON array, NDJSON, or CSV/TSV)'></textarea>
</div>
</div>
<div class="field">
<div class="control">
<button class="button is-primary" type="submit">Preview pasted results</button>
</div>
</div>
</form>
<div id="import-preview"></div>
</section>
{% endblock %}
+3
View File
@@ -12,6 +12,9 @@
<div class="level-item"> <div class="level-item">
<a class="button is-light" href="{% url 'cards:overview' %}">Bulk edit</a> <a class="button is-light" href="{% url 'cards:overview' %}">Bulk edit</a>
</div> </div>
<div class="level-item">
<a class="button is-light" href="{% url 'cards:export_table' %}">Export table</a>
</div>
</div> </div>
</div> </div>
+4
View File
@@ -16,4 +16,8 @@ urlpatterns = [
path('overview/', views.cards_overview, name='overview'), path('overview/', views.cards_overview, name='overview'),
path('overview/bulk-update/', views.cards_bulk_update, name='overview_bulk_update'), path('overview/bulk-update/', views.cards_bulk_update, name='overview_bulk_update'),
path('overview/create-images/', views.cards_bulk_create_images, name='overview_create_images'), path('overview/create-images/', views.cards_bulk_create_images, name='overview_create_images'),
path('export-table/', views.export_table, name='export_table'),
path('import/paste/', views.import_from_paste, name='import_paste'),
path('import/apply/', views.apply_bulk_import, name='import_apply_bulk'),
] ]
+176 -2
View File
@@ -6,6 +6,7 @@ from .models import Dinosaur, BackgroundImage
from .forms import DinosaurBulkFormSet from .forms import DinosaurBulkFormSet
from .forms import DinosaurForm from .forms import DinosaurForm
from .forms import BackgroundImageForm from .forms import BackgroundImageForm
from decimal import Decimal
def is_htmx(request): def is_htmx(request):
@@ -140,7 +141,10 @@ def card_delete(request, pk):
if request.method == 'POST': if request.method == 'POST':
card.delete() card.delete()
if is_htmx(request): if is_htmx(request):
return HttpResponse(status=204) # Return an empty 200 response so HTMX can replace the
# targeted element (e.g. via `hx-swap="outerHTML"`) and
# remove it from the DOM immediately.
return HttpResponse('')
return redirect('cards:list') return redirect('cards:list')
return HttpResponseBadRequest('Only POST allowed') return HttpResponseBadRequest('Only POST allowed')
@@ -197,7 +201,9 @@ def background_delete(request, pk):
bg = get_object_or_404(BackgroundImage, pk=pk) bg = get_object_or_404(BackgroundImage, pk=pk)
bg.delete() bg.delete()
if is_htmx(request): if is_htmx(request):
return HttpResponse(status=204) # See comment in `card_delete`: return empty body so HTMX can
# remove the corresponding element via `hx-swap="outerHTML"`.
return HttpResponse('')
return redirect('cards:backgrounds') return redirect('cards:backgrounds')
@@ -261,3 +267,171 @@ def cards_bulk_create_images(request):
created.append(d) created.append(d)
# Redirect back to overview # Redirect back to overview
return redirect('cards:overview') return redirect('cards:overview')
def export_table(request):
"""Render a page containing a TSV/CSV/Markdown table of all dinosaurs that
can be copied and pasted into an LLM web interface."""
qs = Dinosaur.objects.order_by('name')
# produce a JSON array of objects for easy copy/paste
rows = []
for d in qs:
rows.append({
'pk': d.pk,
'name': d.name,
'speed_kmh': d.speed,
'weight_kg': d.weight,
'height_m': float(d.height),
'intelligence': d.intelligence,
'facts': d.facts_list,
})
import json as _json
json_text = _json.dumps(rows, ensure_ascii=False, indent=2)
return render(request, 'cards/export_table.html', {'json_text': json_text})
def _parse_pasted_input(text):
"""Try to parse pasted LLM output into a list of suggestion dicts.
Accepts JSON array/object, newline-delimited JSON objects, or CSV/TSV with headers.
Each suggestion should contain at least a `name` or `pk` and optional fields:
`speed_kmh`, `weight_kg`, `height_m`, `intelligence_score`, `facts` (list or string).
Returns list of dicts.
"""
text = (text or '').strip()
if not text:
return []
# Try JSON
try:
data = json.loads(text)
if isinstance(data, dict):
return [data]
if isinstance(data, list):
return data
except Exception:
pass
# Try newline-delimited JSON
lines = [l.strip() for l in text.splitlines() if l.strip()]
if len(lines) > 1:
maybe = []
for l in lines:
try:
obj = json.loads(l)
maybe.append(obj)
except Exception:
maybe = []
break
if maybe:
return maybe
# Try CSV/TSV
try:
sniffer = csv.Sniffer()
dialect = sniffer.sniff(text[:1024])
f = StringIO(text)
reader = csv.DictReader(f, dialect=dialect)
out = []
for row in reader:
out.append({k.strip(): (v.strip() if v is not None else '') for k, v in row.items()})
if out:
return out
except Exception:
pass
# As last resort return single-line fallback
return [{'raw': text}]
def import_from_paste(request):
"""Endpoint that accepts pasted LLM output and returns a preview fragment."""
if request.method != 'POST':
return HttpResponseBadRequest('Only POST allowed')
text = request.POST.get('pasted', '')
parsed = _parse_pasted_input(text)
# Normalize parsed entries into suggestion dicts with named fields
suggestions = []
for item in parsed:
# lower-case keys for convenience
entry = {k.lower(): v for k, v in item.items()} if isinstance(item, dict) else {'raw': str(item)}
s = {
'pk': entry.get('pk') or entry.get('id'),
'name': entry.get('name'),
'speed_kmh': entry.get('speed_kmh') or entry.get('speed') or entry.get('speed_km/h'),
'weight_kg': entry.get('weight_kg') or entry.get('weight') or entry.get('mass_kg'),
'height_m': entry.get('height_m') or entry.get('height') or entry.get('height_meters'),
'intelligence_score': entry.get('intelligence_score') or entry.get('intelligence') or entry.get('iq'),
'facts': entry.get('facts') or entry.get('fact') or entry.get('raw'),
'sources': entry.get('sources') or entry.get('source')
}
# convert facts string to list if needed
if isinstance(s['facts'], str):
parts = [p.strip() for p in s['facts'].split('\n') if p.strip()]
if len(parts) == 1 and '|' in parts[0]:
parts = [p.strip() for p in parts[0].split('|') if p.strip()]
s['facts'] = parts
suggestions.append(s)
# provide a JSON-serialized version of suggestions so the preview form can post it
suggestions_json = json.dumps(suggestions, ensure_ascii=False)
return render(request, 'cards/_bulk_import_preview.html', {'suggestions': suggestions, 'suggestions_json': suggestions_json})
def apply_bulk_import(request):
"""Apply pasted suggestions. For now apply all suggestions provided in the
`data` POST field (JSON array). Returns updated list fragment or a simple message."""
if request.method != 'POST':
return HttpResponseBadRequest('Only POST allowed')
data = request.POST.get('data')
if not data:
return HttpResponseBadRequest('No data provided')
try:
parsed = json.loads(data)
except Exception:
return HttpResponseBadRequest('Failed to parse JSON data')
applied = 0
for item in parsed:
name = item.get('name') if isinstance(item, dict) else None
pk = item.get('pk') if isinstance(item, dict) else None
d = None
if pk:
try:
d = Dinosaur.objects.get(pk=pk)
except Dinosaur.DoesNotExist:
d = None
if d is None and name:
try:
d = Dinosaur.objects.get(name__iexact=name)
except Dinosaur.DoesNotExist:
d = None
if not d:
continue
# apply numeric fields if present
def _to_float(v):
try:
return float(v)
except Exception:
return None
speed = _to_float(item.get('speed_kmh'))
weight = _to_float(item.get('weight_kg'))
height = _to_float(item.get('height_m'))
intelligence = _to_float(item.get('intelligence_score') or item.get('intelligence'))
if speed is not None:
d.speed = int(round(speed))
if weight is not None:
d.weight = int(round(weight))
if height is not None:
d.height = Decimal(str(round(height, 1)))
if intelligence is not None:
if 0 <= intelligence <= 5:
d.intelligence = int(round((intelligence / 5.0) * 100))
else:
d.intelligence = int(round(max(0, min(100, intelligence))))
facts = item.get('facts')
if isinstance(facts, list):
d.facts = '\n'.join([str(x).strip() for x in facts if str(x).strip()])
elif isinstance(facts, str):
d.facts = '\n'.join([l.strip() for l in facts.splitlines() if l.strip()])
d.save()
applied += 1
return HttpResponse(f'Applied updates to {applied} dinosaurs')
BIN
View File
Binary file not shown.
+1
View File
@@ -1,2 +1,3 @@
django django
Pillow Pillow
-70
View File
@@ -1,70 +0,0 @@
<article class="card-item" id="card-{{ card.pk }}">
<div class="toptrump-card">
<div class="hero-image" style="background-image: url('{% if card.background and card.background.image %}{{ card.background.image.url }}{% elif background and background.image %}{{ background.image.url }}{% endif %}');">
{% if card.image %}
<img class="card-hero-img" src="{{ card.image.url }}" alt="{{ card.name }}">
{% endif %}
</div>
<div class="title-band">{{ card.name }}</div>
<div class="card-body">
<div class="subtitle is-7"><strong>Era:</strong> {{ card.get_era_display }} — <strong>Diet:</strong> {{ card.get_diet_display }}</div>
{% if card.facts_list %}
<ul class="fact-list is-size-7">
{% for fact in card.facts_list|slice:":2" %}
<li>{{ fact }}</li>
{% endfor %}
</ul>
{% endif %}
<div style="margin-top:0.5rem">
<div class="stat-row">
<div class="stat-label">Speed <small class="is-size-7">(km/h)</small></div>
<div class="stat-bar"><div class="stat-fill" style="width:{{ card.speed_percent }}%"></div></div>
<div class="stat-value" style="margin-left:8px;font-weight:600">
<div>{{ card.speed_display }}</div>
<div class="is-size-7" style="font-weight:500">
{% for t in card.speed_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}
<span style="margin-left:6px">(orig: {{ card.speed_orig }})</span>
</div>
</div>
</div>
<div class="stat-row">
<div class="stat-label">Weight <small class="is-size-7">(kg)</small></div>
<div class="stat-bar"><div class="stat-fill" style="width:{{ card.weight_percent }}%"></div></div>
<div class="stat-value" style="margin-left:8px;font-weight:600">
<div>{{ card.weight_display }}</div>
<div class="is-size-7" style="font-weight:500">
{% for t in card.weight_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}
<span style="margin-left:6px">(orig: {{ card.weight_orig }})</span>
</div>
</div>
</div>
<div class="stat-row">
<div class="stat-label">Height <small class="is-size-7">(m)</small></div>
<div class="stat-bar"><div class="stat-fill" style="width:{{ card.height_percent }}%"></div></div>
<div class="stat-value" style="margin-left:8px;font-weight:600">
<div>{{ card.height_display }}</div>
<div class="is-size-7" style="font-weight:500">
{% for t in card.height_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}
<span style="margin-left:6px">(orig: {{ card.height_orig }})</span>
</div>
</div>
</div>
<div class="stat-row">
<div class="stat-label">Intelligence <small class="is-size-7">(score)</small></div>
<div class="stat-bar"><div class="stat-fill" style="width:{{ card.intelligence_percent }}%"></div></div>
<div class="stat-value" style="margin-left:8px;font-weight:600">
<div class="is-size-7" style="font-weight:600">
{% for t in card.intelligence_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}
</div>
<div class="is-size-7" style="font-weight:500">(orig: {{ card.intelligence_orig }})</div>
</div>
</div>
</div>
<div class="buttons" style="margin-top:0.6rem">
<button class="button is-small is-info" hx-get="{% url 'cards:edit' card.pk %}" hx-target="#htmx-form" hx-swap="innerHTML">Edit</button>
<button class="button is-small is-primary" hx-get="{% url 'cards:preview' card.pk %}" hx-target="#modal-root" hx-swap="innerHTML">Preview</button>
<button class="button is-small is-danger" hx-post="{% url 'cards:delete' card.pk %}" hx-confirm="Delete this card?" hx-swap="none">Delete</button>
</div>
</div>
</div>
</article>
-10
View File
@@ -1,10 +0,0 @@
{% load static %}
<div class="columns is-multiline">
{% for card in cards %}
<div class="column is-3-desktop is-4-tablet is-6-mobile">
{% include 'cards/_card_item.html' with card=card background=background %}
</div>
{% empty %}
<div class="notification is-warning">No dinosaurs yet — add one!</div>
{% endfor %}
</div>
-193
View File
@@ -1,193 +0,0 @@
<div class="card-form card">
{% if card %}
<form method="post" enctype="multipart/form-data" action="{% url 'cards:edit' card.pk %}" hx-post="{% url 'cards:edit' card.pk %}" hx-target="#card-{{ card.pk }}" hx-swap="outerHTML">
{% else %}
<form method="post" enctype="multipart/form-data" action="{% url 'cards:create' %}" hx-post="{% url 'cards:create' %}" hx-target="#cards-list .columns" hx-swap="afterbegin">
{% endif %}
{% csrf_token %}
{{ form.non_field_errors }}
<div class="field">
<label class="label">{{ form.name.label }}</label>
<div class="control">
{{ form.name }}
</div>
</div>
<div class="field is-horizontal">
<div class="field-body">
<div class="field">
<label class="label">{{ form.era.label }}</label>
<div class="control">
<div class="select is-fullwidth">{{ form.era }}</div>
</div>
</div>
<div class="field">
<label class="label">{{ form.diet.label }}</label>
<div class="control">
<div class="select is-fullwidth">{{ form.diet }}</div>
</div>
</div>
</div>
</div>
<div class="field">
<label class="label">{{ form.description.label }}</label>
<div class="control">
{{ form.description }}
</div>
</div>
<div class="field">
<label class="label">{{ form.facts.label }}</label>
<div class="control">
{{ form.facts }}
<p class="help">One fact per line — will be shown as bullet points on the card.</p>
</div>
</div>
<div class="field">
<label class="label">{{ form.image.label }}</label>
<div class="control">
<div class="file has-name">
<label class="file-label">
{{ form.image }}
<span class="file-cta">
<span class="file-icon">📁</span>
<span class="file-label">Choose a file…</span>
</span>
<span class="file-name" style="margin-left:0.5rem">No file chosen</span>
</label>
</div>
</div>
</div>
<div class="field">
<label class="label">{{ form.background.label }}</label>
<div class="control">
<div style="display:none">{{ form.background }}</div>
<div class="background-picker">
<div class="bg-options">
<div class="bg-option" data-bg-id="" role="button" tabindex="0">Use site default</div>
{% for bg in backgrounds %}
<div class="bg-option{% if form.instance.background and form.instance.background.pk == bg.pk %} selected{% endif %}" data-bg-id="{{ bg.pk }}" title="{{ bg.title|default:'Background' }}">
<img src="{{ bg.image.url }}" alt="{{ bg.title }}">
</div>
{% endfor %}
</div>
<p class="help">Optional: choose a background image to use for this card.</p>
</div>
</div>
</div>
<script>
(function(){
const script = document.currentScript;
const root = script && script.parentElement ? script.parentElement : document;
const picker = root.querySelector('.background-picker');
if(!picker) return;
const select = root.querySelector('select[name="background"]');
const options = picker.querySelectorAll('.bg-option');
function clearSelected(){ options.forEach(o=>o.classList.remove('selected')); }
options.forEach(function(opt){
opt.addEventListener('click', function(){
const id = opt.getAttribute('data-bg-id');
clearSelected();
opt.classList.add('selected');
if(select){ select.value = id || '';
// dispatch change for any listeners
select.dispatchEvent(new Event('change', { bubbles: true }));
}
});
});
})();
</script>
<script>
(function(){
const script = document.currentScript;
const formRoot = script && script.parentElement ? script.parentElement : document;
const fileInputs = formRoot.querySelectorAll('input[type=file]');
fileInputs.forEach(function(fi){
fi.addEventListener('change', function(){
const wrapper = fi.closest('.file');
if(!wrapper) return;
const label = wrapper.querySelector('.file-name');
if(!label) return;
if(fi.files.length === 0) label.textContent = 'No file chosen';
else label.textContent = fi.files.length > 1 ? fi.files.length + ' files selected' : fi.files[0].name;
try {
const nameInput = formRoot.querySelector('input[name="name"]');
if(nameInput && (!nameInput.value || nameInput.value.trim() === '')){
const f = fi.files && fi.files[0];
if(f && f.name){
const base = f.name.replace(/\.[^/.]+$/, '');
const title = base.replace(/[_-]+/g, ' ').split(/\s+/).map(function(w){ return w.length? (w.charAt(0).toUpperCase() + w.slice(1)) : ''; }).join(' ').trim();
if(title) nameInput.value = title;
}
}
}catch(e){ /* ignore UI enhancement errors */ }
});
const outerLabel = fi.closest('.file').querySelector('.file-label');
if(outerLabel && !outerLabel.contains(fi)){
outerLabel.addEventListener('click', function(e){ fi.click(); });
}
});
})();
</script>
<div class="field is-horizontal">
<div class="field-body">
<div class="field">
<label class="label">{{ form.speed.label }}</label>
<div class="control">
<div class="field has-addons">
<div class="control is-expanded">{{ form.speed }}</div>
<div class="control">
<a class="button is-static">km/h</a>
</div>
</div>
</div>
</div>
<div class="field">
<label class="label">{{ form.weight.label }}</label>
<div class="control">
<div class="field has-addons">
<div class="control is-expanded">{{ form.weight }}</div>
<div class="control">
<a class="button is-static">kg</a>
</div>
</div>
</div>
</div>
<div class="field">
<label class="label">{{ form.intelligence.label }}</label>
<div class="control">
<div class="field has-addons">
<div class="control is-expanded">{{ form.intelligence }}</div>
<div class="control">
<a class="button is-static">score</a>
</div>
</div>
</div>
</div>
<div class="field">
<label class="label">{{ form.height.label }}</label>
<div class="control">
<div class="field has-addons">
<div class="control is-expanded">{{ form.height }}</div>
<div class="control">
<a class="button is-static">m</a>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="field is-grouped">
<div class="control">
<button class="button is-primary" type="submit">Save</button>
</div>
<div class="control">
<button class="button is-light" type="button" onclick="document.getElementById('htmx-form').innerHTML=''">Cancel</button>
</div>
</div>
</form>
</div>
-147
View File
@@ -1,147 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>TopTrumps - Dinosaurs</title>
<!-- Small inline favicon to avoid browser requesting /favicon.ico and spamming 404s -->
<link rel="icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
<script src="https://unpkg.com/htmx.org@1.9.5"></script>
<style>
/* Theme variables */
:root{
--bg:#ffffff;
--text:#0a0a0a;
--muted:#4a4a4a;
--card-bg:#ffffff;
--accent:#00d1b2;
}
[data-theme="dark"]{
--bg:#0b0f13;
--text:#e6eef3;
--muted:#9aa8b0;
--card-bg:#0f1620;
--accent:#79ffd4;
}
body{background:var(--bg); color:var(--text)}
.app-container{padding:1.5rem}
.card-item{min-height:140px; background:var(--card-bg); color:var(--text)}
.highlight-new{animation: highlight 1.6s ease}
@keyframes highlight{0%{background:lavenderblush}100%{background:transparent}}
/* Theme toggle */
#theme-toggle{margin-left:0.5rem}
/* Top Trumps card styles */
.toptrump-card{max-width:320px;border-radius:10px;overflow:hidden;background:var(--card-bg);color:var(--text);box-shadow:0 6px 18px rgba(0,0,0,0.12);border:1px solid rgba(0,0,0,0.06)}
.toptrump-card .hero-image{height:180px;background-size:cover;background-position:center;position:relative;overflow:hidden}
/* Hero image: make the dinosaur image larger, cover the banner and remove shadow */
.toptrump-card .hero-image img.card-hero-img{
position:absolute;
left:50%;
top:50%;
transform:translate(-50%,-50%);
/* ensure the image fills the banner while keeping aspect ratio */
min-width:110%;
min-height:110%;
width:auto;
height:auto;
object-fit:cover;
border-radius:6px;
box-shadow:none;
}
/* Background picker thumbnails */
.background-picker{margin-top:0.5rem}
.background-picker .bg-options{display:flex;gap:0.5rem;flex-wrap:wrap}
.background-picker .bg-option{width:64px;height:48px;border:1px solid rgba(0,0,0,0.06);display:flex;align-items:center;justify-content:center;cursor:pointer;background:#fff;color:#222;padding:4px;border-radius:4px}
.background-picker .bg-option img{max-width:100%;max-height:100%;display:block;border-radius:2px}
.background-picker .bg-option.selected{outline:2px solid var(--accent);box-shadow:0 2px 6px rgba(0,0,0,0.08)}
.background-picker .bg-option[role="button"]{font-size:0.8rem;padding:6px 8px}
/* Bulk edit thumbnails */
.bulk-thumb{width:48px;height:48px;object-fit:cover;border-radius:6px;border:1px solid rgba(0,0,0,0.06);display:inline-block}
.bulk-thumb.empty{background:rgba(0,0,0,0.04);width:48px;height:48px;border-radius:6px}
.toptrump-card .title-band{background:var(--accent);color:#072027;padding:0.6rem 0.75rem;text-align:center;font-weight:700;font-size:1.05rem}
.toptrump-card .card-body{padding:0.75rem}
.toptrump-card .stat-row{display:flex;align-items:center;gap:0.75rem;padding:0.25rem 0}
.toptrump-card .stat-label{width:90px;font-weight:600}
.toptrump-card .stat-bar{flex:1;height:12px;background:rgba(255,255,255,0.06);border-radius:8px;overflow:hidden}
.toptrump-card .stat-fill{height:100%;background:linear-gradient(90deg,var(--accent),#ffdd57);}
.toptrump-card .fact-list{margin-top:0.5rem;padding-left:1rem}
/* Star rating visuals */
.star { font-size:0.95rem; display:inline-block; line-height:1; margin-left:2px; }
.star.full { color:#ffdd57; }
.star.empty { color:rgba(255,255,255,0.25); }
.star.half { color:rgba(255,255,255,0.25); position:relative; display:inline-block; }
.star.half::before { content: '★'; color:#ffdd57; position:absolute; left:0; top:0; width:50%; overflow:hidden; display:inline-block; }
.stat-value { text-align:right; min-width:6.5rem }
</style>
</head>
<body>
<section class="section">
<div class="container app-container">
<div class="level">
<div class="level-left">
<h1 class="title">TopTrumps — Dinosaurs</h1>
</div>
<div class="level-right">
<div class="level-item">
<button id="theme-toggle" class="button is-small" aria-pressed="false">Toggle theme</button>
</div>
</div>
</div>
<div id="messages"></div>
{% block content %}{% endblock %}
<div id="modal-root"></div>
</div>
</section>
</body>
<script>
(function(){
const root = document.documentElement;
const toggle = document.getElementById('theme-toggle');
function applyTheme(t){
if(t==='dark') root.setAttribute('data-theme','dark');
else root.removeAttribute('data-theme');
if(toggle) toggle.setAttribute('aria-pressed', t==='dark');
}
// initial: localStorage -> prefers-color-scheme -> light
const stored = localStorage.getItem('toptrumps-theme');
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
applyTheme(stored || (prefersDark ? 'dark' : 'light'));
if(toggle){
toggle.addEventListener('click', ()=>{
const current = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
const next = current === 'dark' ? 'light' : 'dark';
applyTheme(next);
localStorage.setItem('toptrumps-theme', next);
});
}
})();
</script>
<script>
// Ensure HTMX requests include Django CSRF token (reads from cookie)
(function(){
function getCookie(name){
let cookieValue = null;
if (document.cookie && document.cookie !== ''){
const cookies = document.cookie.split(';');
for (let i=0;i<cookies.length;i++){
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')){
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
document.body.addEventListener('htmx:configRequest', function(evt){
const csrftoken = getCookie('csrftoken');
if (csrftoken){
evt.detail.headers['X-CSRFToken'] = csrftoken;
}
});
})();
</script>
</html>
-134
View File
@@ -1,134 +0,0 @@
{% extends "cards/base.html" %}
{% block content %}
<section class="section">
<h2 class="title is-4">Cards overview — bulk edit</h2>
<p class="subtitle is-6">Edit multiple cards inline and save changes in one go.</p>
<style>
/* Allow inputs inside the table to wrap on narrow viewports */
.table-container .field.has-addons{white-space:normal}
.table-container .control.is-expanded{width:100%}
.table-container input.input{min-width:50px}
/* Ensure thumbnails don't shrink layout unexpectedly */
.bulk-thumb{flex:none}
/* Make this page full-bleed: allow the app container to use the full viewport width */
.app-container{max-width:none; width:100%; padding-left:0.5rem; padding-right:0.5rem}
/* Remove extra section padding so content touches edges if needed */
section.section{padding-left:0; padding-right:0}
/* Table container: allow horizontal scroll and make table use full available width */
.table-container{width:100%; overflow-x:auto}
.table-container .table{min-width:1100px}
</style>
<div style="margin-bottom:1rem">
<form method="post" action="{% url 'cards:overview_create_images' %}" enctype="multipart/form-data" style="display:inline-block; margin-right:1rem">
{% csrf_token %}
<div class="file has-name">
<label class="file-label">
<input class="file-input" type="file" name="images" multiple>
<span class="file-cta"><span class="file-icon">📁</span><span class="file-label">Select images…</span></span>
</label>
</div>
<button class="button is-small is-link" type="submit">Create cards from images</button>
</form>
</div>
<form method="post" action="{% url 'cards:overview_bulk_update' %}">
{% csrf_token %}
<div class="table-container">
<table class="table is-fullwidth is-striped">
<thead>
<tr>
<th>Preview</th>
<th>Name</th>
<th>Background</th>
<th>Speed</th>
<th>Weight</th>
<th>Height</th>
<th>Intel</th>
<th></th>
</tr>
</thead>
<tbody>
{% if formset %}
{{ formset.management_form }}
{% for form in formset.forms %}
{{ form.id }}
<tr>
<td>
{% if form.instance.image %}
<img class="bulk-thumb" src="{{ form.instance.image.url }}" alt="{{ form.instance.name }}">
{% else %}
<div class="bulk-thumb empty"></div>
{% endif %}
</td>
<td>{{ form.name }}</td>
<td>{{ form.background }}</td>
<td style="width:90px">{{ form.speed }}</td>
<td style="width:90px">{{ form.weight }}</td>
<td style="width:90px">{{ form.height }}</td>
<td style="width:90px">{{ form.intelligence }}</td>
<td style="width:90px">ID: {{ form.instance.pk }}</td>
</tr>
{% endfor %}
{% else %}
{% for card in cards %}
<tr>
<td>
{% if card.image %}
<img class="bulk-thumb" src="{{ card.image.url }}" alt="{{ card.name }}">
{% else %}
<div class="bulk-thumb empty"></div>
{% endif %}
</td>
<td><input class="input" name="name-{{ card.pk }}" value="{{ card.name }}"></td>
<td>
<div class="select"><select name="background-{{ card.pk }}">
<option value="">Site default</option>
{% for bg in backgrounds %}
<option value="{{ bg.pk }}"{% if card.background and card.background.pk == bg.pk %} selected{% endif %}>{{ bg.title|default:'Background' }}</option>
{% endfor %}
</select></div>
</td>
<td style="width:90px">
<div class="field has-addons">
<div class="control is-expanded"><input class="input" name="speed-{{ card.pk }}" value="{{ card.speed }}"></div>
<div class="control"><a class="button is-static">km/h</a></div>
</div>
</td>
<td style="width:90px">
<div class="field has-addons">
<div class="control is-expanded"><input class="input" name="weight-{{ card.pk }}" value="{{ card.weight }}"></div>
<div class="control"><a class="button is-static">kg</a></div>
</div>
</td>
<td style="width:90px">
<div class="field has-addons">
<div class="control is-expanded"><input class="input" name="height-{{ card.pk }}" value="{{ card.height }}"></div>
<div class="control"><a class="button is-static">m</a></div>
</div>
</td>
<td style="width:90px">
<div class="field has-addons">
<div class="control is-expanded"><input class="input" name="intelligence-{{ card.pk }}" value="{{ card.intelligence }}"></div>
<div class="control"><a class="button is-static">score</a></div>
</div>
</td>
<td>ID: {{ card.pk }}</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
<div class="field">
<div class="control">
<button class="button is-primary" type="submit">Save changes</button>
<a class="button is-light" href="{% url 'cards:list' %}">Back to list</a>
</div>
</div>
</form>
</section>
{% endblock %}
-24
View File
@@ -1,24 +0,0 @@
{% extends 'cards/base.html' %}
{% block content %}
<div class="level">
<div class="level-left">
<div class="level-item">
<button class="button is-primary" hx-get="{% url 'cards:create' %}" hx-target="#htmx-form" hx-swap="innerHTML">Add Dinosaur</button>
</div>
<div class="level-item">
<a class="button is-light" href="{% url 'cards:backgrounds' %}">Manage backgrounds</a>
</div>
<div class="level-item">
<a class="button is-light" href="{% url 'cards:overview' %}">Bulk edit</a>
</div>
</div>
</div>
<div id="htmx-form"></div>
<div id="cards-list">
{% include 'cards/_cards_list.html' %}
</div>
{% endblock %}