.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{% extends 'cards/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
{% include 'cards/_card_preview.html' %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
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 |
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user