Compare commits

...
4 Commits
Author SHA1 Message Date
Ross be1fea5fd4 . 2025-12-04 22:49:16 +00:00
Ross 23f7caa937 Refactor code structure for improved readability and maintainability 2025-12-04 22:41:39 +00:00
Ross fbfffd1a6e . 2025-12-04 22:36:27 +00:00
Ross d299f7ffd3 Add BackgroundImage model and enhance card templates with dynamic backgrounds 2025-12-04 22:16:33 +00:00
30 changed files with 668 additions and 52 deletions
+58
View File
@@ -0,0 +1,58 @@
# OS
.DS_Store
Thumbs.db
desktop.ini
$RECYCLE.BIN/
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log
# Node
node_modules/
dist/
build/
out/
coverage/
.nyc_output/
# Environment
.env
.env.*.local
# IDEs and editors
.vscode/
.idea/
*.sublime-workspace
*.sublime-project
*.code-workspace
# Package managers
package-lock.json
.pnpm-store/
# (keep yarn.lock if you rely on it; remove above line to keep package-lock.json in repo)
# Python
__pycache__/
*.py[cod]
venv/
ENV/
env/
# Java / JVM
target/
*.class
*.jar
*.war
*.ear
*.iml
# Misc
*.sqlite
*.db
*.swp
*~
+8 -1
View File
@@ -1,8 +1,15 @@
from django.contrib import admin from django.contrib import admin
from .models import Dinosaur from .models import Dinosaur, BackgroundImage
@admin.register(Dinosaur) @admin.register(Dinosaur)
class DinosaurAdmin(admin.ModelAdmin): class DinosaurAdmin(admin.ModelAdmin):
list_display = ('name', 'era', 'diet', 'speed', 'weight', 'intelligence') list_display = ('name', 'era', 'diet', 'speed', 'weight', 'intelligence')
search_fields = ('name', 'era', 'diet') search_fields = ('name', 'era', 'diet')
@admin.register(BackgroundImage)
class BackgroundImageAdmin(admin.ModelAdmin):
list_display = ('__str__', 'is_active', 'uploaded_at')
list_filter = ('is_active',)
search_fields = ('title',)
+18
View File
@@ -8,6 +8,7 @@ class DinosaurForm(forms.ModelForm):
fields = [ fields = [
'name', 'era', 'diet', 'description', 'image', 'name', 'era', 'diet', 'description', 'image',
'speed', 'weight', 'intelligence', 'height', 'speed', 'weight', 'intelligence', 'height',
'facts',
] ]
widgets = { widgets = {
'name': forms.TextInput(attrs={'class': 'input', 'placeholder': 'T. rex'}), 'name': forms.TextInput(attrs={'class': 'input', 'placeholder': 'T. rex'}),
@@ -20,3 +21,20 @@ class DinosaurForm(forms.ModelForm):
'intelligence': forms.NumberInput(attrs={'class': 'input', 'min': 0, 'max': 100}), 'intelligence': forms.NumberInput(attrs={'class': 'input', 'min': 0, 'max': 100}),
'height': forms.NumberInput(attrs={'class': 'input', 'min': 0, 'max': 100}), '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
class BackgroundImageForm(forms.ModelForm):
class Meta:
model = BackgroundImage
fields = ['title', 'image', 'is_active']
widgets = {
'title': forms.TextInput(attrs={'class': 'input', 'placeholder': 'Rocky desert'}),
'image': forms.ClearableFileInput(attrs={'class': 'file-input'}),
'is_active': forms.CheckboxInput(attrs={'class': 'checkbox'}),
}
+26
View File
@@ -0,0 +1,26 @@
# Generated by Django 6.0 on 2025-12-04 22:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cards', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='BackgroundImage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(blank=True, max_length=200)),
('image', models.ImageField(upload_to='backgrounds/')),
('is_active', models.BooleanField(default=False, help_text='Mark as the active background')),
('uploaded_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['-is_active', '-uploaded_at'],
},
),
]
+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'),
),
]
+34
View File
@@ -25,6 +25,7 @@ class Dinosaur(models.Model):
weight = models.PositiveSmallIntegerField(default=50) weight = models.PositiveSmallIntegerField(default=50)
intelligence = models.PositiveSmallIntegerField(default=50) intelligence = models.PositiveSmallIntegerField(default=50)
height = 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) created_at = models.DateTimeField(auto_now_add=True)
@@ -33,3 +34,36 @@ class Dinosaur(models.Model):
def __str__(self): def __str__(self):
return self.name 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.
Admins can upload multiple backgrounds and mark one as active. Templates
will use BackgroundImage.get_active() to obtain the active background.
"""
title = models.CharField(max_length=200, blank=True)
image = models.ImageField(upload_to='backgrounds/')
is_active = models.BooleanField(default=False, help_text='Mark as the active background')
uploaded_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-is_active', '-uploaded_at']
def __str__(self):
return self.title or f"Background #{self.pk}"
@classmethod
def get_active(cls):
"""Return the active BackgroundImage if set, otherwise the most recent one or None."""
active = cls.objects.filter(is_active=True).order_by('-uploaded_at').first()
if active:
return active
return cls.objects.order_by('-uploaded_at').first()
@@ -0,0 +1,55 @@
{% load static %}
<form hx-post="{% url 'cards:background_create' %}" hx-target="#backgrounds-list" hx-swap="innerHTML" enctype="multipart/form-data">
{% csrf_token %}
<div class="field">
<label class="label">Title</label>
<div class="control">
{{ form.title }}
</div>
</div>
<div class="field">
<label class="label">Image</label>
<div class="control">
<div class="file">
<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">
<div class="control">
<label class="checkbox">{{ form.is_active }} Set active</label>
</div>
</div>
<div class="field">
<div class="control">
<button class="button is-primary" type="submit">Upload</button>
</div>
</div>
</form>
<script>
// Show selected filename in the Bulma file-name span
(function(){
const form = document.currentScript && document.currentScript.previousElementSibling ? document.currentScript.previousElementSibling : null;
// If script is placed at the end of form, walk up to find file input(s)
const fileInputs = (form && form.querySelectorAll) ? form.querySelectorAll('input[type=file]') : document.querySelectorAll('input[type=file]');
fileInputs.forEach(function(fi){
fi.addEventListener('change', function(e){
const label = fi.closest('.file').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;
});
});
})();
</script>
@@ -0,0 +1,25 @@
<div id="backgrounds-list">
<div class="columns is-multiline">
{% for bg in backgrounds %}
<div class="column is-3-desktop is-4-tablet is-6-mobile">
<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="margin-top:0.5rem">
<strong>{{ bg.title }}</strong>
<div class="is-size-7">Uploaded {{ bg.uploaded_at }}</div>
<div class="buttons" style="margin-top:0.5rem">
{% if not bg.is_active %}
<button class="button is-small" hx-post="{% url 'cards:background_activate' bg.pk %}" hx-swap="outerHTML" hx-target="#backgrounds-list">Set active</button>
{% else %}
<span class="tag is-success">Active</span>
{% 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>
</div>
</div>
</article>
</div>
{% empty %}
<div class="notification is-warning">No backgrounds yet — upload one!</div>
{% endfor %}
</div>
</div>
+26 -19
View File
@@ -1,23 +1,30 @@
<article class="box card-item" id="card-{{ card.pk }}"> <article class="card-item" id="card-{{ card.pk }}">
<div class="media"> <div class="toptrump-card">
<div class="media-content"> <div class="hero-image" style="background-image: url('{% if background and background.image %}{{ background.image.url }}{% endif %}');">
<p class="title is-5">{{ card.name }}</p> {% if card.image %}
<p class="subtitle is-7"><strong>Era:</strong> {{ card.get_era_display }} — <strong>Diet:</strong> {{ card.get_diet_display }}</p> <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>
</div> <div class="title-band">{{ card.name }}</div>
<div class="content"> <div class="card-body">
<div>{{ card.description|linebreaksbr }}</div> <div class="subtitle is-7"><strong>Era:</strong> {{ card.get_era_display }} — <strong>Diet:</strong> {{ card.get_diet_display }}</div>
<table class="table is-fullwidth is-narrow"> {% if card.facts_list %}
<tbody> <ul class="fact-list is-size-7">
<tr><th>Speed</th><td>{{ card.speed }}</td></tr> {% for fact in card.facts_list|slice:":2" %}
<tr><th>Weight</th><td>{{ card.weight }}</td></tr> <li>{{ fact }}</li>
<tr><th>Intelligence</th><td>{{ card.intelligence }}</td></tr> {% endfor %}
<tr><th>Height</th><td>{{ card.height }}</td></tr> </ul>
</tbody> {% endif %}
</table> <div style="margin-top:0.5rem">
<div class="buttons"> <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>
<button class="button is-small is-info" hx-get="{% url 'cards:edit' card.pk %}" hx-target="#htmx-form" hx-swap="innerHTML">Edit</button> <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>
<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 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="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>
</div> </div>
</article> </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>
+1 -1
View File
@@ -2,7 +2,7 @@
<div class="columns is-multiline"> <div class="columns is-multiline">
{% for card in cards %} {% for card in cards %}
<div class="column is-3-desktop is-4-tablet is-6-mobile"> <div class="column is-3-desktop is-4-tablet is-6-mobile">
{% include 'cards/_card_item.html' with card=card %} {% include 'cards/_card_item.html' with card=card background=background %}
</div> </div>
{% empty %} {% empty %}
<div class="notification is-warning">No dinosaurs yet — add one!</div> <div class="notification is-warning">No dinosaurs yet — add one!</div>
+40
View File
@@ -38,16 +38,56 @@
</div> </div>
</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"> <div class="field">
<label class="label">{{ form.image.label }}</label> <label class="label">{{ form.image.label }}</label>
<div class="control"> <div class="control">
<div class="file has-name"> <div class="file has-name">
<label class="file-label"> <label class="file-label">
{{ form.image }} {{ 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> </label>
</div> </div>
</div> </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 is-horizontal">
<div class="field-body"> <div class="field-body">
@@ -0,0 +1,30 @@
{% extends 'cards/base.html' %}
{% block content %}
<div class="level">
<div class="level-left">
<div class="level-item">
<h2 class="title is-4">Background images</h2>
</div>
</div>
<div class="level-right">
<div class="level-item">
<a class="button is-light" href="{% url 'cards:list' %}">Back to cards</a>
</div>
</div>
</div>
<div class="columns">
<div class="column is-half">
<div id="htmx-background-form">
{% include 'cards/_background_form.html' with form=form %}
</div>
</div>
<div class="column is-half">
<div id="backgrounds-list">
{% include 'cards/_backgrounds_list.html' with backgrounds=backgrounds %}
</div>
</div>
</div>
{% endblock %}
+65 -2
View File
@@ -7,19 +7,82 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css"> <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> <script src="https://unpkg.com/htmx.org@1.9.5"></script>
<style> <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} .app-container{padding:1.5rem}
.card-item{min-height:140px} .card-item{min-height:140px; background:var(--card-bg); color:var(--text)}
.highlight-new{animation: highlight 1.6s ease} .highlight-new{animation: highlight 1.6s ease}
@keyframes highlight{0%{background:lavenderblush}100%{background:transparent}} @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}
.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> </style>
</head> </head>
<body> <body>
<section class="section"> <section class="section">
<div class="container app-container"> <div class="container app-container">
<h1 class="title">TopTrumps — Dinosaurs</h1> <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> <div id="messages"></div>
{% block content %}{% endblock %} {% block content %}{% endblock %}
<div id="modal-root"></div>
</div> </div>
</section> </section>
</body> </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>
</html> </html>
+3
View File
@@ -6,6 +6,9 @@
<div class="level-item"> <div class="level-item">
<button class="button is-primary" hx-get="{% url 'cards:create' %}" hx-target="#htmx-form" hx-swap="innerHTML">Add Dinosaur</button> <button class="button is-primary" hx-get="{% url 'cards:create' %}" hx-target="#htmx-form" hx-swap="innerHTML">Add Dinosaur</button>
</div> </div>
<div class="level-item">
<a class="button is-light" href="{% url 'cards:backgrounds' %}">Manage backgrounds</a>
</div>
</div> </div>
</div> </div>
+7
View File
@@ -0,0 +1,7 @@
{% extends 'cards/base.html' %}
{% block content %}
<div class="container">
{% include 'cards/_card_preview.html' %}
</div>
{% endblock %}
+5
View File
@@ -8,4 +8,9 @@ urlpatterns = [
path('create/', views.card_create, name='create'), path('create/', views.card_create, name='create'),
path('<int:pk>/edit/', views.card_edit, name='edit'), path('<int:pk>/edit/', views.card_edit, name='edit'),
path('<int:pk>/delete/', views.card_delete, name='delete'), path('<int:pk>/delete/', views.card_delete, name='delete'),
path('preview/<int:pk>/', views.preview_card, name='preview'),
path('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'),
path('backgrounds/<int:pk>/delete/', views.background_delete, name='background_delete'),
] ]
+77 -7
View File
@@ -1,8 +1,9 @@
from django.shortcuts import render, get_object_or_404, redirect from django.shortcuts import render, get_object_or_404, redirect
from django.http import HttpResponse, HttpResponseBadRequest from django.http import HttpResponse, HttpResponseBadRequest
from django.template.loader import render_to_string from django.template.loader import render_to_string
from .models import Dinosaur from .models import Dinosaur, BackgroundImage
from .forms import DinosaurForm from .forms import DinosaurForm
from .forms import BackgroundImageForm
def is_htmx(request): def is_htmx(request):
@@ -11,18 +12,21 @@ def is_htmx(request):
def card_list(request): def card_list(request):
cards = Dinosaur.objects.all() cards = Dinosaur.objects.all()
background = BackgroundImage.get_active() if BackgroundImage.objects.exists() else None
if is_htmx(request): if is_htmx(request):
return render(request, 'cards/_cards_list.html', {'cards': cards}) return render(request, 'cards/_cards_list.html', {'cards': cards, 'background': background})
return render(request, 'cards/list.html', {'cards': cards}) return render(request, 'cards/list.html', {'cards': cards, 'background': background})
def card_create(request): def card_create(request):
background = BackgroundImage.get_active() if BackgroundImage.objects.exists() else None
if request.method == 'POST': if request.method == 'POST':
form = DinosaurForm(request.POST, request.FILES) form = DinosaurForm(request.POST, request.FILES)
if form.is_valid(): if form.is_valid():
card = form.save() card = form.save()
if is_htmx(request): if is_htmx(request):
card_html = render_to_string('cards/_card_item.html', {'card': card}, request=request) card_html = render_to_string('cards/_card_item.html', {'card': card, 'background': background}, request=request)
# Also return an out-of-band fragment to replace/clear the form container # Also return an out-of-band fragment to replace/clear the form container
form_html = render_to_string('cards/_form.html', {'form': DinosaurForm()}, request=request) form_html = render_to_string('cards/_form.html', {'form': DinosaurForm()}, request=request)
oob = f'<div id="htmx-form" hx-swap-oob="true">{form_html}</div>' oob = f'<div id="htmx-form" hx-swap-oob="true">{form_html}</div>'
@@ -37,17 +41,18 @@ def card_create(request):
else: else:
form = DinosaurForm() form = DinosaurForm()
return render(request, 'cards/_form.html', {'form': form}) return render(request, 'cards/_form.html', {'form': form, 'background': background})
def card_edit(request, pk): def card_edit(request, pk):
card = get_object_or_404(Dinosaur, pk=pk) card = get_object_or_404(Dinosaur, pk=pk)
background = BackgroundImage.get_active() if BackgroundImage.objects.exists() else None
if request.method == 'POST': if request.method == 'POST':
form = DinosaurForm(request.POST, request.FILES, instance=card) form = DinosaurForm(request.POST, request.FILES, instance=card)
if form.is_valid(): if form.is_valid():
card = form.save() card = form.save()
if is_htmx(request): if is_htmx(request):
card_html = render_to_string('cards/_card_item.html', {'card': card}, request=request) card_html = render_to_string('cards/_card_item.html', {'card': card, 'background': background}, request=request)
# Clear the form container via OOB so the edit form disappears after successful save # Clear the form container via OOB so the edit form disappears after successful save
oob = '<div id="htmx-form" hx-swap-oob="true"></div>' oob = '<div id="htmx-form" hx-swap-oob="true"></div>'
return HttpResponse(card_html + oob) return HttpResponse(card_html + oob)
@@ -60,7 +65,7 @@ def card_edit(request, pk):
else: else:
form = DinosaurForm(instance=card) form = DinosaurForm(instance=card)
return render(request, 'cards/_form.html', {'form': form, 'card': card}) return render(request, 'cards/_form.html', {'form': form, 'card': card, 'background': background})
def card_delete(request, pk): def card_delete(request, pk):
@@ -71,3 +76,68 @@ def card_delete(request, pk):
return HttpResponse(status=204) return HttpResponse(status=204)
return redirect('cards:list') return redirect('cards:list')
return HttpResponseBadRequest('Only POST allowed') return HttpResponseBadRequest('Only POST allowed')
def background_list(request):
"""Show existing backgrounds and provide a form to upload a new one."""
backgrounds = BackgroundImage.objects.all()
form = BackgroundImageForm()
if is_htmx(request):
return render(request, 'cards/_backgrounds_list.html', {'backgrounds': backgrounds, 'form': form})
return render(request, 'cards/backgrounds_list.html', {'backgrounds': backgrounds, 'form': form})
def background_create(request):
if request.method != 'POST':
return HttpResponseBadRequest('Only POST allowed')
form = BackgroundImageForm(request.POST, request.FILES)
if form.is_valid():
bg = form.save()
# If marked active, unset others
if bg.is_active:
BackgroundImage.objects.exclude(pk=bg.pk).update(is_active=False)
if is_htmx(request):
# return the updated list fragment and clear the form OOB
list_html = render_to_string('cards/_backgrounds_list.html', {'backgrounds': BackgroundImage.objects.all(), 'form': BackgroundImageForm()}, request=request)
oob = f'<div id="htmx-background-form" hx-swap-oob="true">{render_to_string("cards/_background_form.html", {"form": BackgroundImageForm()}, request=request)}</div>'
return HttpResponse(list_html + oob)
return redirect('cards:backgrounds')
else:
if is_htmx(request):
form_html = render_to_string('cards/_background_form.html', {'form': form}, request=request)
oob = f'<div id="htmx-background-form" hx-swap-oob="true">{form_html}</div>'
return HttpResponse(oob)
return render(request, 'cards/backgrounds_list.html', {'backgrounds': BackgroundImage.objects.all(), 'form': form})
def background_activate(request, pk):
if request.method != 'POST':
return HttpResponseBadRequest('Only POST allowed')
bg = get_object_or_404(BackgroundImage, pk=pk)
BackgroundImage.objects.update(is_active=False)
bg.is_active = True
bg.save()
if is_htmx(request):
list_html = render_to_string('cards/_backgrounds_list.html', {'backgrounds': BackgroundImage.objects.all(), 'form': BackgroundImageForm()}, request=request)
return HttpResponse(list_html)
return redirect('cards:backgrounds')
def background_delete(request, pk):
if request.method != 'POST':
return HttpResponseBadRequest('Only POST allowed')
bg = get_object_or_404(BackgroundImage, pk=pk)
bg.delete()
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: 6.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 MiB

+26 -19
View File
@@ -1,23 +1,30 @@
<article class="box card-item" id="card-{{ card.pk }}"> <article class="card-item" id="card-{{ card.pk }}">
<div class="media"> <div class="toptrump-card">
<div class="media-content"> <div class="hero-image" style="background-image: url('{% if background and background.image %}{{ background.image.url }}{% endif %}');">
<p class="title is-5">{{ card.name }}</p> {% if card.image %}
<p class="subtitle is-7"><strong>Era:</strong> {{ card.get_era_display }} — <strong>Diet:</strong> {{ card.get_diet_display }}</p> <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>
</div> <div class="title-band">{{ card.name }}</div>
<div class="content"> <div class="card-body">
<div>{{ card.description|linebreaksbr }}</div> <div class="subtitle is-7"><strong>Era:</strong> {{ card.get_era_display }} — <strong>Diet:</strong> {{ card.get_diet_display }}</div>
<table class="table is-fullwidth is-narrow"> {% if card.facts_list %}
<tbody> <ul class="fact-list is-size-7">
<tr><th>Speed</th><td>{{ card.speed }}</td></tr> {% for fact in card.facts_list|slice:":2" %}
<tr><th>Weight</th><td>{{ card.weight }}</td></tr> <li>{{ fact }}</li>
<tr><th>Intelligence</th><td>{{ card.intelligence }}</td></tr> {% endfor %}
<tr><th>Height</th><td>{{ card.height }}</td></tr> </ul>
</tbody> {% endif %}
</table> <div style="margin-top:0.5rem">
<div class="buttons"> <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>
<button class="button is-small is-info" hx-get="{% url 'cards:edit' card.pk %}" hx-target="#htmx-form" hx-swap="innerHTML">Edit</button> <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>
<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 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="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>
</div> </div>
</article> </article>
+1 -1
View File
@@ -2,7 +2,7 @@
<div class="columns is-multiline"> <div class="columns is-multiline">
{% for card in cards %} {% for card in cards %}
<div class="column is-3-desktop is-4-tablet is-6-mobile"> <div class="column is-3-desktop is-4-tablet is-6-mobile">
{% include 'cards/_card_item.html' with card=card %} {% include 'cards/_card_item.html' with card=card background=background %}
</div> </div>
{% empty %} {% empty %}
<div class="notification is-warning">No dinosaurs yet — add one!</div> <div class="notification is-warning">No dinosaurs yet — add one!</div>
+34
View File
@@ -37,16 +37,50 @@
</div> </div>
</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"> <div class="field">
<label class="label">{{ form.image.label }}</label> <label class="label">{{ form.image.label }}</label>
<div class="control"> <div class="control">
<div class="file has-name"> <div class="file has-name">
<label class="file-label"> <label class="file-label">
{{ form.image }} {{ 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> </label>
</div> </div>
</div> </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 is-horizontal">
<div class="field-body"> <div class="field-body">
+65 -2
View File
@@ -7,19 +7,82 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css"> <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> <script src="https://unpkg.com/htmx.org@1.9.5"></script>
<style> <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} .app-container{padding:1.5rem}
.card-item{min-height:140px} .card-item{min-height:140px; background:var(--card-bg); color:var(--text)}
.highlight-new{animation: highlight 1.6s ease} .highlight-new{animation: highlight 1.6s ease}
@keyframes highlight{0%{background:lavenderblush}100%{background:transparent}} @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}
.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> </style>
</head> </head>
<body> <body>
<section class="section"> <section class="section">
<div class="container app-container"> <div class="container app-container">
<h1 class="title">TopTrumps — Dinosaurs</h1> <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> <div id="messages"></div>
{% block content %}{% endblock %} {% block content %}{% endblock %}
<div id="modal-root"></div>
</div> </div>
</section> </section>
</body> </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>
</html> </html>
+3
View File
@@ -6,6 +6,9 @@
<div class="level-item"> <div class="level-item">
<button class="button is-primary" hx-get="{% url 'cards:create' %}" hx-target="#htmx-form" hx-swap="innerHTML">Add Dinosaur</button> <button class="button is-primary" hx-get="{% url 'cards:create' %}" hx-target="#htmx-form" hx-swap="innerHTML">Add Dinosaur</button>
</div> </div>
<div class="level-item">
<a class="button is-light" href="{% url 'cards:backgrounds' %}">Manage backgrounds</a>
</div>
</div> </div>
</div> </div>