Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3566666cc2 | ||
|
|
346cfa3f5f | ||
|
|
0155fa39b0 | ||
|
|
011a5acce7 | ||
|
|
6e4f41007b | ||
|
|
322c22f14e | ||
|
|
ceea07d0b3 | ||
|
|
5de97c5ee0 |
@@ -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')
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 }}
|
||||||
|
—
|
||||||
|
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
Binary file not shown.
@@ -1,2 +1,3 @@
|
|||||||
django
|
django
|
||||||
Pillow
|
Pillow
|
||||||
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{% load static %}
|
|
||||||
<div class="columns is-multiline">
|
|
||||||
{% for card in cards %}
|
|
||||||
<div class="column is-3-desktop is-4-tablet is-6-mobile">
|
|
||||||
{% include 'cards/_card_item.html' with card=card background=background %}
|
|
||||||
</div>
|
|
||||||
{% empty %}
|
|
||||||
<div class="notification is-warning">No dinosaurs yet — add one!</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
@@ -1,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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -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 %}
|
|
||||||
Reference in New Issue
Block a user