This commit is contained in:
Ross
2025-12-04 22:49:16 +00:00
parent 23f7caa937
commit be1fea5fd4
16 changed files with 240 additions and 52 deletions
+4
View File
@@ -8,6 +8,7 @@ class DinosaurForm(forms.ModelForm):
fields = [
'name', 'era', 'diet', 'description', 'image',
'speed', 'weight', 'intelligence', 'height',
'facts',
]
widgets = {
'name': forms.TextInput(attrs={'class': 'input', 'placeholder': 'T. rex'}),
@@ -21,6 +22,9 @@ class DinosaurForm(forms.ModelForm):
'height': forms.NumberInput(attrs={'class': 'input', 'min': 0, 'max': 100}),
}
# Add facts field widget
widgets['facts'] = forms.Textarea(attrs={'class': 'textarea', 'rows': 3, 'placeholder': 'One fact per line'})
from .models import BackgroundImage
+18
View File
@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2025-12-04 22:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cards', '0002_backgroundimage'),
]
operations = [
migrations.AddField(
model_name='dinosaur',
name='facts',
field=models.TextField(blank=True, help_text='One fact per line; shown as bullet points on the card'),
),
]
+8
View File
@@ -25,6 +25,7 @@ class Dinosaur(models.Model):
weight = models.PositiveSmallIntegerField(default=50)
intelligence = models.PositiveSmallIntegerField(default=50)
height = models.PositiveSmallIntegerField(default=50)
facts = models.TextField(blank=True, help_text='One fact per line; shown as bullet points on the card')
created_at = models.DateTimeField(auto_now_add=True)
@@ -34,6 +35,13 @@ class Dinosaur(models.Model):
def __str__(self):
return self.name
@property
def facts_list(self):
"""Return facts as a list of non-empty lines."""
if not self.facts:
return []
return [f.strip() for f in self.facts.splitlines() if f.strip()]
class BackgroundImage(models.Model):
"""Background images used behind card renders.
+23 -22
View File
@@ -1,29 +1,30 @@
<article class="box card-item" id="card-{{ card.pk }}"
{% if background and background.image %}style="background-image: url('{{ background.image.url }}'); background-size: cover; background-position: center; position: relative;"{% endif %}>
<div class="media">
<div class="media-content">
<p class="title is-5">{{ card.name }}</p>
<p class="subtitle is-7"><strong>Era:</strong> {{ card.get_era_display }} — <strong>Diet:</strong> {{ card.get_diet_display }}</p>
<article class="card-item" id="card-{{ card.pk }}">
<div class="toptrump-card">
<div class="hero-image" style="background-image: url('{% if background and background.image %}{{ background.image.url }}{% endif %}');">
{% if card.image %}
<img src="{{ card.image.url }}" alt="{{ card.name }}" style="display:block;margin:16px auto 0; max-width:72%; height:auto; object-fit:cover; box-shadow:0 4px 12px rgba(0,0,0,0.3); border-radius:6px;">
{% endif %}
</div>
<div class="title-band">{{ card.name }}</div>
<div class="card-body">
<div class="subtitle is-7"><strong>Era:</strong> {{ card.get_era_display }} — <strong>Diet:</strong> {{ card.get_diet_display }}</div>
{% if card.facts_list %}
<ul class="fact-list is-size-7">
{% for fact in card.facts_list|slice:":2" %}
<li>{{ fact }}</li>
{% endfor %}
</ul>
{% endif %}
<div style="margin-top:0.5rem">
<div class="stat-row"><div class="stat-label">Speed</div><div class="stat-bar"><div class="stat-fill" style="width:{{ card.speed }}%"></div></div></div>
<div class="stat-row"><div class="stat-label">Weight</div><div class="stat-bar"><div class="stat-fill" style="width:{{ card.weight }}%"></div></div></div>
<div class="stat-row"><div class="stat-label">Intelligence</div><div class="stat-bar"><div class="stat-fill" style="width:{{ card.intelligence }}%"></div></div></div>
</div>
<div class="content">
<div>{{ card.description|linebreaksbr }}</div>
<table class="table is-fullwidth is-narrow">
<tbody>
<tr><th>Speed</th><td>{{ card.speed }}</td></tr>
<tr><th>Weight</th><td>{{ card.weight }}</td></tr>
<tr><th>Intelligence</th><td>{{ card.intelligence }}</td></tr>
<tr><th>Height</th><td>{{ card.height }}</td></tr>
</tbody>
</table>
<div class="buttons">
<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>
{% if card.image %}
<figure class="image is-96x96" style="position:absolute; right:0.75rem; top:0.75rem; width:96px; height:96px; background:transparent;">
<img src="{{ card.image.url }}" alt="{{ card.name }}" style="width:96px; height:96px; object-fit:cover; border-radius:6px; box-shadow:0 2px 6px rgba(0,0,0,0.3);">
</figure>
{% endif %}
</div>
</article>
+43
View File
@@ -0,0 +1,43 @@
{# HTMX modal fragment for previewing a card #}
{# HTMX modal fragment for previewing a card in Top Trumps style #}
<div class="modal is-active" id="card-preview-modal">
<div class="modal-background" onclick="document.getElementById('modal-root').innerHTML=''" ></div>
<div class="modal-content">
<div style="max-width:920px; margin:0 auto;">
<div style="display:flex; justify-content:center;">
<div class="toptrump-card">
{# Hero / image banner using background image if available #}
<div class="hero-image" style="background-image: url('{% if background and background.image %}{{ background.image.url }}{% endif %}');">
{% if card.image %}
<img src="{{ card.image.url }}" alt="{{ card.name }}" style="position:relative; display:block; margin:18px auto 0; max-width:78%; height:auto; object-fit:contain; box-shadow:0 6px 18px rgba(0,0,0,0.35); border-radius:6px;">
{% endif %}
</div>
<div class="title-band">{{ card.name }}</div>
<div class="card-body">
<div class="subtitle is-6"><strong>Era:</strong> {{ card.get_era_display }} — <strong>Diet:</strong> {{ card.get_diet_display }}</div>
<div style="margin-top:0.5rem">{{ card.description|linebreaksbr }}</div>
{% if card.facts_list %}
<div>
<strong>Fun facts</strong>
<ul class="fact-list">
{% for fact in card.facts_list %}
<li>{{ fact }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<div style="margin-top:0.8rem">
<div class="stat-row"><div class="stat-label">Speed</div><div class="stat-bar"><div class="stat-fill" style="width:{{ card.speed }}%"></div></div><div style="width:36px;text-align:right;font-weight:700">{{ card.speed }}</div></div>
<div class="stat-row"><div class="stat-label">Weight</div><div class="stat-bar"><div class="stat-fill" style="width:{{ card.weight }}%"></div></div><div style="width:36px;text-align:right;font-weight:700">{{ card.weight }}</div></div>
<div class="stat-row"><div class="stat-label">Intelligence</div><div class="stat-bar"><div class="stat-fill" style="width:{{ card.intelligence }}%"></div></div><div style="width:36px;text-align:right;font-weight:700">{{ card.intelligence }}</div></div>
<div class="stat-row"><div class="stat-label">Height</div><div class="stat-bar"><div class="stat-fill" style="width:{{ card.height }}%"></div></div><div style="width:36px;text-align:right;font-weight:700">{{ card.height }}</div></div>
</div>
</div>
</div>
</div>
</div>
</div>
<button class="modal-close is-large" aria-label="close" onclick="document.getElementById('modal-root').innerHTML=''"></button>
</div>
+40
View File
@@ -38,16 +38,56 @@
</div>
</div>
<div class="field">
<label class="label">{{ form.facts.label }}</label>
<div class="control">
{{ form.facts }}
<p class="help">One fact per line — will be shown as bullet points on the card.</p>
</div>
</div>
<div class="field">
<label class="label">{{ form.image.label }}</label>
<div class="control">
<div class="file has-name">
<label class="file-label">
{{ form.image }}
<span class="file-cta">
<span class="file-icon">📁</span>
<span class="file-label">Choose a file…</span>
</span>
<span class="file-name" style="margin-left:0.5rem">No file chosen</span>
</label>
</div>
</div>
</div>
<script>
(function(){
// Attach to file inputs within this form fragment and update the .file-name text
// Use currentScript to scope to this form when HTMX injects the fragment.
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;
});
// Make sure clicking the visual label triggers the input if not already clickable
const outerLabel = fi.closest('.file').querySelector('.file-label');
if(outerLabel && !outerLabel.contains(fi)){
outerLabel.addEventListener('click', function(e){
// forward click to the hidden file input
fi.click();
});
}
});
})();
</script>
<div class="field is-horizontal">
<div class="field-body">
+11
View File
@@ -31,6 +31,16 @@
/* Theme toggle */
#theme-toggle{margin-left:0.5rem}
/* Top Trumps card styles */
.toptrump-card{max-width:320px;border-radius:10px;overflow:hidden;background:var(--card-bg);color:var(--text);box-shadow:0 6px 18px rgba(0,0,0,0.12);border:1px solid rgba(0,0,0,0.06)}
.toptrump-card .hero-image{height:180px;background-size:cover;background-position:center}
.toptrump-card .title-band{background:var(--accent);color:#072027;padding:0.6rem 0.75rem;text-align:center;font-weight:700;font-size:1.05rem}
.toptrump-card .card-body{padding:0.75rem}
.toptrump-card .stat-row{display:flex;align-items:center;gap:0.75rem;padding:0.25rem 0}
.toptrump-card .stat-label{width:90px;font-weight:600}
.toptrump-card .stat-bar{flex:1;height:12px;background:rgba(255,255,255,0.06);border-radius:8px;overflow:hidden}
.toptrump-card .stat-fill{height:100%;background:linear-gradient(90deg,var(--accent),#ffdd57);}
.toptrump-card .fact-list{margin-top:0.5rem;padding-left:1rem}
</style>
</head>
<body>
@@ -48,6 +58,7 @@
</div>
<div id="messages"></div>
{% block content %}{% endblock %}
<div id="modal-root"></div>
</div>
</section>
</body>
+7
View File
@@ -0,0 +1,7 @@
{% extends 'cards/base.html' %}
{% block content %}
<div class="container">
{% include 'cards/_card_preview.html' %}
</div>
{% endblock %}
+1
View File
@@ -8,6 +8,7 @@ urlpatterns = [
path('create/', views.card_create, name='create'),
path('<int:pk>/edit/', views.card_edit, name='edit'),
path('<int:pk>/delete/', views.card_delete, name='delete'),
path('preview/<int:pk>/', views.preview_card, name='preview'),
path('backgrounds/', views.background_list, name='backgrounds'),
path('backgrounds/create/', views.background_create, name='background_create'),
path('backgrounds/<int:pk>/activate/', views.background_activate, name='background_activate'),
+9
View File
@@ -132,3 +132,12 @@ def background_delete(request, pk):
if is_htmx(request):
return HttpResponse(status=204)
return redirect('cards:backgrounds')
def preview_card(request, pk):
"""Render a full preview of a card. HTMX loads the modal fragment into #modal-root."""
card = get_object_or_404(Dinosaur, pk=pk)
background = BackgroundImage.get_active() if BackgroundImage.objects.exists() else None
if is_htmx(request):
return render(request, 'cards/_card_preview.html', {'card': card, 'background': background})
return render(request, 'cards/preview.html', {'card': card, 'background': background})
BIN
View File
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 MiB

+23 -22
View File
@@ -1,29 +1,30 @@
<article class="box card-item" id="card-{{ card.pk }}"
{% if background and background.image %}style="background-image: url('{{ background.image.url }}'); background-size: cover; background-position: center; position: relative;"{% endif %}>
<div class="media">
<div class="media-content">
<p class="title is-5">{{ card.name }}</p>
<p class="subtitle is-7"><strong>Era:</strong> {{ card.get_era_display }} — <strong>Diet:</strong> {{ card.get_diet_display }}</p>
<article class="card-item" id="card-{{ card.pk }}">
<div class="toptrump-card">
<div class="hero-image" style="background-image: url('{% if background and background.image %}{{ background.image.url }}{% endif %}');">
{% if card.image %}
<img src="{{ card.image.url }}" alt="{{ card.name }}" style="display:block;margin:16px auto 0; max-width:72%; height:auto; object-fit:cover; box-shadow:0 4px 12px rgba(0,0,0,0.3); border-radius:6px;">
{% endif %}
</div>
<div class="title-band">{{ card.name }}</div>
<div class="card-body">
<div class="subtitle is-7"><strong>Era:</strong> {{ card.get_era_display }} — <strong>Diet:</strong> {{ card.get_diet_display }}</div>
{% if card.facts_list %}
<ul class="fact-list is-size-7">
{% for fact in card.facts_list|slice:":2" %}
<li>{{ fact }}</li>
{% endfor %}
</ul>
{% endif %}
<div style="margin-top:0.5rem">
<div class="stat-row"><div class="stat-label">Speed</div><div class="stat-bar"><div class="stat-fill" style="width:{{ card.speed }}%"></div></div></div>
<div class="stat-row"><div class="stat-label">Weight</div><div class="stat-bar"><div class="stat-fill" style="width:{{ card.weight }}%"></div></div></div>
<div class="stat-row"><div class="stat-label">Intelligence</div><div class="stat-bar"><div class="stat-fill" style="width:{{ card.intelligence }}%"></div></div></div>
</div>
<div class="content">
<div>{{ card.description|linebreaksbr }}</div>
<table class="table is-fullwidth is-narrow">
<tbody>
<tr><th>Speed</th><td>{{ card.speed }}</td></tr>
<tr><th>Weight</th><td>{{ card.weight }}</td></tr>
<tr><th>Intelligence</th><td>{{ card.intelligence }}</td></tr>
<tr><th>Height</th><td>{{ card.height }}</td></tr>
</tbody>
</table>
<div class="buttons">
<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>
{% if card.image %}
<figure class="image is-96x96" style="position:absolute; right:0.75rem; top:0.75rem; width:96px; height:96px; background:transparent;">
<img src="{{ card.image.url }}" alt="{{ card.name }}" style="width:96px; height:96px; object-fit:cover; border-radius:6px; box-shadow:0 2px 6px rgba(0,0,0,0.3);">
</figure>
{% endif %}
</div>
</article>
+34
View File
@@ -37,16 +37,50 @@
</div>
</div>
<div class="field">
<label class="label">{{ form.facts.label }}</label>
<div class="control">
{{ form.facts }}
<p class="help">One fact per line — will be shown as bullet points on the card.</p>
</div>
</div>
<div class="field">
<label class="label">{{ form.image.label }}</label>
<div class="control">
<div class="file has-name">
<label class="file-label">
{{ form.image }}
<span class="file-cta">
<span class="file-icon">📁</span>
<span class="file-label">Choose a file…</span>
</span>
<span class="file-name" style="margin-left:0.5rem">No file chosen</span>
</label>
</div>
</div>
</div>
<script>
(function(){
const script = document.currentScript;
const formRoot = script && script.parentElement ? script.parentElement : document;
const fileInputs = formRoot.querySelectorAll('input[type=file]');
fileInputs.forEach(function(fi){
fi.addEventListener('change', function(){
const wrapper = fi.closest('.file');
if(!wrapper) return;
const label = wrapper.querySelector('.file-name');
if(!label) return;
if(fi.files.length === 0) label.textContent = 'No file chosen';
else label.textContent = fi.files.length > 1 ? fi.files.length + ' files selected' : fi.files[0].name;
});
const outerLabel = fi.closest('.file').querySelector('.file-label');
if(outerLabel && !outerLabel.contains(fi)){
outerLabel.addEventListener('click', function(e){ fi.click(); });
}
});
})();
</script>
<div class="field is-horizontal">
<div class="field-body">
+11
View File
@@ -31,6 +31,16 @@
/* Theme toggle */
#theme-toggle{margin-left:0.5rem}
/* Top Trumps card styles */
.toptrump-card{max-width:320px;border-radius:10px;overflow:hidden;background:var(--card-bg);color:var(--text);box-shadow:0 6px 18px rgba(0,0,0,0.12);border:1px solid rgba(0,0,0,0.06)}
.toptrump-card .hero-image{height:180px;background-size:cover;background-position:center}
.toptrump-card .title-band{background:var(--accent);color:#072027;padding:0.6rem 0.75rem;text-align:center;font-weight:700;font-size:1.05rem}
.toptrump-card .card-body{padding:0.75rem}
.toptrump-card .stat-row{display:flex;align-items:center;gap:0.75rem;padding:0.25rem 0}
.toptrump-card .stat-label{width:90px;font-weight:600}
.toptrump-card .stat-bar{flex:1;height:12px;background:rgba(255,255,255,0.06);border-radius:8px;overflow:hidden}
.toptrump-card .stat-fill{height:100%;background:linear-gradient(90deg,var(--accent),#ffdd57);}
.toptrump-card .fact-list{margin-top:0.5rem;padding-left:1rem}
</style>
</head>
<body>
@@ -48,6 +58,7 @@
</div>
<div id="messages"></div>
{% block content %}{% endblock %}
<div id="modal-root"></div>
</div>
</section>
</body>