Add BackgroundImage model and enhance card templates with dynamic backgrounds
This commit is contained in:
+58
@@ -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
@@ -1,8 +1,15 @@
|
||||
from django.contrib import admin
|
||||
from .models import Dinosaur
|
||||
from .models import Dinosaur, BackgroundImage
|
||||
|
||||
|
||||
@admin.register(Dinosaur)
|
||||
class DinosaurAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'era', 'diet', 'speed', 'weight', 'intelligence')
|
||||
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',)
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -33,3 +33,29 @@ class Dinosaur(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
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()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<article class="box card-item" id="card-{{ card.pk }}">
|
||||
<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>
|
||||
@@ -20,4 +21,9 @@
|
||||
<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 %}
|
||||
</article>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<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 %}
|
||||
{% include 'cards/_card_item.html' with card=card background=background %}
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="notification is-warning">No dinosaurs yet — add one!</div>
|
||||
|
||||
@@ -7,19 +7,71 @@
|
||||
<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}
|
||||
.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}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<section class="section">
|
||||
<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>
|
||||
{% block content %}{% endblock %}
|
||||
</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>
|
||||
</html>
|
||||
|
||||
+11
-7
@@ -1,7 +1,7 @@
|
||||
from django.shortcuts import render, get_object_or_404, redirect
|
||||
from django.http import HttpResponse, HttpResponseBadRequest
|
||||
from django.template.loader import render_to_string
|
||||
from .models import Dinosaur
|
||||
from .models import Dinosaur, BackgroundImage
|
||||
from .forms import DinosaurForm
|
||||
|
||||
|
||||
@@ -11,18 +11,21 @@ def is_htmx(request):
|
||||
|
||||
def card_list(request):
|
||||
cards = Dinosaur.objects.all()
|
||||
background = BackgroundImage.get_active() if BackgroundImage.objects.exists() else None
|
||||
if is_htmx(request):
|
||||
return render(request, 'cards/_cards_list.html', {'cards': cards})
|
||||
return render(request, 'cards/list.html', {'cards': cards})
|
||||
return render(request, 'cards/_cards_list.html', {'cards': cards, 'background': background})
|
||||
return render(request, 'cards/list.html', {'cards': cards, 'background': background})
|
||||
|
||||
|
||||
def card_create(request):
|
||||
background = BackgroundImage.get_active() if BackgroundImage.objects.exists() else None
|
||||
|
||||
if request.method == 'POST':
|
||||
form = DinosaurForm(request.POST, request.FILES)
|
||||
if form.is_valid():
|
||||
card = form.save()
|
||||
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
|
||||
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>'
|
||||
@@ -37,17 +40,18 @@ def card_create(request):
|
||||
else:
|
||||
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):
|
||||
card = get_object_or_404(Dinosaur, pk=pk)
|
||||
background = BackgroundImage.get_active() if BackgroundImage.objects.exists() else None
|
||||
if request.method == 'POST':
|
||||
form = DinosaurForm(request.POST, request.FILES, instance=card)
|
||||
if form.is_valid():
|
||||
card = form.save()
|
||||
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
|
||||
oob = '<div id="htmx-form" hx-swap-oob="true"></div>'
|
||||
return HttpResponse(card_html + oob)
|
||||
@@ -60,7 +64,7 @@ def card_edit(request, pk):
|
||||
else:
|
||||
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):
|
||||
|
||||
BIN
Binary file not shown.
@@ -1,4 +1,5 @@
|
||||
<article class="box card-item" id="card-{{ card.pk }}">
|
||||
<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>
|
||||
@@ -20,4 +21,9 @@
|
||||
<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 %}
|
||||
</article>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<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 %}
|
||||
{% include 'cards/_card_item.html' with card=card background=background %}
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="notification is-warning">No dinosaurs yet — add one!</div>
|
||||
|
||||
@@ -7,19 +7,71 @@
|
||||
<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}
|
||||
.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}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<section class="section">
|
||||
<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>
|
||||
{% block content %}{% endblock %}
|
||||
</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>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user