vormgeving: compleet thema (schoolnaam + kleuren) + logo-rechten gefixt
This commit is contained in:
parent
d8c4523dc0
commit
dd52b81b02
11 changed files with 169 additions and 27 deletions
11
ROADMAP.md
11
ROADMAP.md
|
|
@ -46,10 +46,13 @@ volgt.
|
|||
koptekst, **kleurenlegenda** en **elke dag op nieuwe pagina** — plus
|
||||
**papierformaat** (A4/A3/A5/Letter) en oriëntatie (liggend/staand).
|
||||
Afdruk-koptekst met logo + schooljaar + week + afdrukdatum.
|
||||
- [x] **Module Huisstijl** (`huisstijl`, default aan): schoollogo uploaden via
|
||||
Instellingen (PNG/SVG/JPG/GIF/WebP) met **kwaliteitscheck** (bestandstype,
|
||||
resolutie, transparantie/achtergrond — waarschuwt bij gevulde/witte
|
||||
achtergrond). Logo verschijnt in elke roosterweergave en op afdrukken.
|
||||
- [x] **Module Vormgeving / thema** (`huisstijl`, default aan): schoollogo
|
||||
uploaden via Instellingen (PNG/SVG/JPG/GIF/WebP) met **kwaliteitscheck**
|
||||
(bestandstype, resolutie, transparantie/achtergrond). Logo verschijnt in
|
||||
elke roosterweergave en op afdrukken. Daarnaast **schoolnaam** (in de balk
|
||||
+ op afdrukken) en **thema-kleuren** (primair + accent) die app-breed
|
||||
worden toegepast als CSS-variabelen. Geüploade media wereldwijd-leesbaar
|
||||
(FILE_UPLOAD_PERMISSIONS) en self-healing volume-rechten via gosu-entrypoint.
|
||||
|
||||
- [x] **Vakkleur als vlak** (toggle 🎨, onthouden per browser): vak volledig in de
|
||||
activiteitskleur i.p.v. een streepje, met automatisch leesbare tekstkleur
|
||||
|
|
|
|||
|
|
@ -155,9 +155,13 @@ USE_TZ = True
|
|||
STATIC_URL = "/static/"
|
||||
STATIC_ROOT = os.environ.get("DJANGO_STATIC_ROOT", str(BASE_DIR / "staticfiles"))
|
||||
|
||||
# Geüploade bestanden (o.a. pictogrammen bij vakken).
|
||||
# Geüploade bestanden (o.a. pictogrammen bij vakken, schoollogo).
|
||||
MEDIA_URL = "/media/"
|
||||
MEDIA_ROOT = os.environ.get("DJANGO_MEDIA_ROOT", str(BASE_DIR / "media"))
|
||||
# Geüploade bestanden wereldwijd-leesbaar wegschrijven, anders kan nginx (die als
|
||||
# een andere gebruiker draait) ze niet serveren → gebroken afbeeldingen.
|
||||
FILE_UPLOAD_PERMISSIONS = 0o644
|
||||
FILE_UPLOAD_DIRECTORY_PERMISSIONS = 0o755
|
||||
|
||||
# E-mail (voor o.a. wachtwoordreset). Zonder SMTP-config gaat e-mail naar de
|
||||
# console-log (handig voor test); zet DJANGO_EMAIL_BACKEND/EMAIL_* in productie.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ class HuisstijlConfig(AppConfig):
|
|||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "modules.huisstijl"
|
||||
label = "module_huisstijl"
|
||||
verbose_name = "Module: Logo"
|
||||
verbose_name = "Module: Vormgeving"
|
||||
|
||||
def ready(self):
|
||||
from plugins.registry import MenuItem, ModuleSpec, registry
|
||||
|
|
@ -13,7 +13,7 @@ class HuisstijlConfig(AppConfig):
|
|||
registry.register(
|
||||
ModuleSpec(
|
||||
key="huisstijl",
|
||||
name="Logo",
|
||||
name="Vormgeving",
|
||||
description=(
|
||||
"Toont het schoollogo in elke roosterweergave en op afdrukken. "
|
||||
"Logo te uploaden via Instellingen, met een kwaliteitscheck "
|
||||
|
|
@ -24,7 +24,7 @@ class HuisstijlConfig(AppConfig):
|
|||
core=False,
|
||||
default_enabled=True,
|
||||
menu_items=(
|
||||
MenuItem("Logo", "/logo", icon="badge", order=74, group="Instellingen"),
|
||||
MenuItem("Vormgeving", "/vormgeving", icon="badge", order=74, group="Instellingen"),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
|
|
|||
26
backend/modules/huisstijl/migrations/0002_thema_velden.py
Normal file
26
backend/modules/huisstijl/migrations/0002_thema_velden.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("module_huisstijl", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="huisstijl",
|
||||
name="schoolnaam",
|
||||
field=models.CharField(blank=True, default="", max_length=120, verbose_name="schoolnaam"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="huisstijl",
|
||||
name="primaire_kleur",
|
||||
field=models.CharField(blank=True, default="", max_length=9, verbose_name="primaire kleur"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="huisstijl",
|
||||
name="accent_kleur",
|
||||
field=models.CharField(blank=True, default="", max_length=9, verbose_name="accentkleur"),
|
||||
),
|
||||
]
|
||||
|
|
@ -2,10 +2,14 @@ from django.db import models
|
|||
|
||||
|
||||
class Huisstijl(models.Model):
|
||||
"""Eén-rij-instelling met het schoollogo dat in elke roosterweergave en op
|
||||
afdrukken getoond wordt."""
|
||||
"""Eén-rij-instelling met de vormgeving (thema) van de school: logo,
|
||||
schoolnaam en kleuren. Wordt in elke roosterweergave en op afdrukken
|
||||
toegepast."""
|
||||
|
||||
logo = models.FileField("logo", upload_to="huisstijl/", null=True, blank=True)
|
||||
schoolnaam = models.CharField("schoolnaam", max_length=120, blank=True, default="")
|
||||
primaire_kleur = models.CharField("primaire kleur", max_length=9, blank=True, default="")
|
||||
accent_kleur = models.CharField("accentkleur", max_length=9, blank=True, default="")
|
||||
aangepast_op = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
|
|
|
|||
|
|
@ -8,4 +8,4 @@ class HuisstijlSerializer(serializers.ModelSerializer):
|
|||
|
||||
class Meta:
|
||||
model = Huisstijl
|
||||
fields = ["id", "logo", "aangepast_op"]
|
||||
fields = ["id", "logo", "schoolnaam", "primaire_kleur", "accent_kleur", "aangepast_op"]
|
||||
|
|
|
|||
|
|
@ -109,25 +109,39 @@ def huisstijl(request):
|
|||
return Response(HuisstijlSerializer(obj, context={"request": request}).data)
|
||||
|
||||
if request.method in ("PUT", "POST"):
|
||||
analyse = None
|
||||
# Thema-tekstvelden (kleuren/naam) — uit JSON of form-data.
|
||||
for veld in ("schoolnaam", "primaire_kleur", "accent_kleur"):
|
||||
if veld in request.data:
|
||||
waarde = (request.data.get(veld) or "").strip()
|
||||
if veld.endswith("_kleur") and waarde and not _is_kleur(waarde):
|
||||
return Response({"detail": f"Ongeldige kleur voor {veld}: gebruik een hex-code zoals #2563eb."}, status=400)
|
||||
setattr(obj, veld, waarde)
|
||||
# Optioneel een nieuw logo.
|
||||
f = request.FILES.get("logo")
|
||||
if not f:
|
||||
return Response({"detail": "Geen bestand ontvangen (veld 'logo')."}, status=400)
|
||||
if f:
|
||||
try:
|
||||
analyse = analyseer_logo(f)
|
||||
except Exception as e: # noqa: BLE001 — geef een leesbare reden i.p.v. 500.
|
||||
return Response({"detail": f"Kon de afbeelding niet verwerken: {e}"}, status=400)
|
||||
if not analyse["ok"]:
|
||||
return Response({"detail": " ".join(analyse["fouten"]), "kwaliteit": analyse}, status=400)
|
||||
try:
|
||||
f.seek(0)
|
||||
if obj.logo:
|
||||
obj.logo.delete(save=False)
|
||||
obj.logo = f
|
||||
try:
|
||||
obj.save()
|
||||
except Exception as e: # noqa: BLE001
|
||||
return Response({"detail": f"Opslaan van het logo mislukte: {e}"}, status=400)
|
||||
return Response({"detail": f"Opslaan mislukte: {e}"}, status=400)
|
||||
data = HuisstijlSerializer(obj, context={"request": request}).data
|
||||
if analyse is not None:
|
||||
data["kwaliteit"] = analyse
|
||||
return Response(data)
|
||||
|
||||
return Response(HuisstijlSerializer(obj, context={"request": request}).data)
|
||||
|
||||
|
||||
def _is_kleur(s):
|
||||
import re
|
||||
return bool(re.fullmatch(r"#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})", s or ""))
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { getMe, login, logout, getModules, setModuleState, wachtwoordVergeten } from "./api.js";
|
||||
import { getMe, login, logout, getModules, setModuleState, wachtwoordVergeten, getHuisstijl } from "./api.js";
|
||||
import Logo from "./Logo.jsx";
|
||||
import {
|
||||
PersonenPage, FunctiesPage,
|
||||
|
|
@ -47,9 +47,29 @@ const PAGES = {
|
|||
"/vak-wizard": VakWizardPage,
|
||||
"/personen-wizard": PersonenWizardPage,
|
||||
"/auto-rooster": AutoRoosterPage,
|
||||
"/logo": HuisstijlPage,
|
||||
"/vormgeving": HuisstijlPage,
|
||||
};
|
||||
|
||||
// Thema (vormgeving) app-breed toepassen via CSS-variabelen.
|
||||
function hexNaarRgba(hex, a) {
|
||||
if (!hex) return "";
|
||||
const m = hex.replace("#", "");
|
||||
const v = m.length === 3 ? m.split("").map((c) => c + c).join("") : m;
|
||||
if (v.length < 6) return "";
|
||||
const r = parseInt(v.slice(0, 2), 16), g = parseInt(v.slice(2, 4), 16), b = parseInt(v.slice(4, 6), 16);
|
||||
if ([r, g, b].some(Number.isNaN)) return "";
|
||||
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
||||
}
|
||||
export function pasThemaToe(h) {
|
||||
const root = document.documentElement;
|
||||
if (h && h.primaire_kleur) {
|
||||
root.style.setProperty("--brand", h.primaire_kleur);
|
||||
const soft = hexNaarRgba(h.primaire_kleur, 0.14);
|
||||
if (soft) root.style.setProperty("--brand-soft", soft);
|
||||
}
|
||||
if (h && h.accent_kleur) root.style.setProperty("--accent", h.accent_kleur);
|
||||
}
|
||||
|
||||
const RESET_PARAMS = (() => {
|
||||
try {
|
||||
const q = new URLSearchParams(window.location.search);
|
||||
|
|
@ -181,6 +201,11 @@ function Shell({ user, onLogout }) {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [cogOpen, setCogOpen] = useState(false);
|
||||
const [openMenu, setOpenMenu] = useState(null);
|
||||
const [schoolnaam, setSchoolnaam] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
getHuisstijl().then((h) => { pasThemaToe(h); setSchoolnaam(h && h.schoolnaam ? h.schoolnaam : ""); }).catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
|
|
@ -338,6 +363,7 @@ function Shell({ user, onLogout }) {
|
|||
<div className="brand">
|
||||
<span className="brand-logo"><Logo size={30} /></span>
|
||||
<span className="brand-name">Roosterwijs</span>
|
||||
{schoolnaam && <span className="brand-schoolnaam">· {schoolnaam}</span>}
|
||||
</div>
|
||||
<div className="topbar-user">
|
||||
<span className="muted small">{user.username}</span>
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ export const logout = () => request("POST", "/auth/logout/");
|
|||
|
||||
// --- Huisstijl (schoollogo) -----------------------------------------------
|
||||
export const getHuisstijl = () => request("GET", "/huisstijl/");
|
||||
export const opslaanVormgeving = (data) => request("PUT", "/huisstijl/", data);
|
||||
export const verwijderLogo = () => request("DELETE", "/huisstijl/");
|
||||
export async function uploadLogo(file) {
|
||||
const fd = new FormData();
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
getHuisstijl,
|
||||
uploadLogo,
|
||||
verwijderLogo,
|
||||
opslaanVormgeving,
|
||||
} from "./api.js";
|
||||
|
||||
const ROLLEN = [
|
||||
|
|
@ -2830,7 +2831,7 @@ export function RoosterScherm({ mode }) {
|
|||
<div className="inst-body"><p className="muted small">Vrije locatie-aanduidingen (bv. gymzaal, zwembad).</p><LocatiesPage /></div>
|
||||
</details>
|
||||
<details className="inst-sectie">
|
||||
<summary><span className="ico">🖼️</span> Logo</summary>
|
||||
<summary><span className="ico">🖼️</span> Vormgeving</summary>
|
||||
<div className="inst-body"><HuisstijlPage /></div>
|
||||
</details>
|
||||
</div>
|
||||
|
|
@ -3281,7 +3282,22 @@ export function RoosterLogo({ print = false }) {
|
|||
const [logo, setLogo] = useState(null);
|
||||
useEffect(() => { getHuisstijl().then((h) => setLogo(h && h.logo ? h.logo : null)).catch(() => {}); }, []);
|
||||
if (!logo) return null;
|
||||
return <img src={logo} alt="Logo" className={print ? "rooster-logo print-logo" : "rooster-logo no-print"} />;
|
||||
return <img src={logo} alt="" className={print ? "rooster-logo print-logo" : "rooster-logo no-print"} />;
|
||||
}
|
||||
|
||||
// Pas thema-kleuren direct toe (zelfde logica als App.pasThemaToe; hier
|
||||
// gedupliceerd om een circulaire import te vermijden).
|
||||
function _themaDirect(h) {
|
||||
const root = document.documentElement;
|
||||
const rgba = (hex, a) => {
|
||||
if (!hex) return "";
|
||||
const m = hex.replace("#", ""); const v = m.length === 3 ? m.split("").map((c) => c + c).join("") : m;
|
||||
if (v.length < 6) return "";
|
||||
const r = parseInt(v.slice(0, 2), 16), g = parseInt(v.slice(2, 4), 16), b = parseInt(v.slice(4, 6), 16);
|
||||
return [r, g, b].some(Number.isNaN) ? "" : `rgba(${r}, ${g}, ${b}, ${a})`;
|
||||
};
|
||||
if (h && h.primaire_kleur) { root.style.setProperty("--brand", h.primaire_kleur); const s = rgba(h.primaire_kleur, 0.14); if (s) root.style.setProperty("--brand-soft", s); }
|
||||
if (h && h.accent_kleur) root.style.setProperty("--accent", h.accent_kleur);
|
||||
}
|
||||
|
||||
export function HuisstijlPage() {
|
||||
|
|
@ -3289,8 +3305,33 @@ export function HuisstijlPage() {
|
|||
const [kwaliteit, setKwaliteit] = useState(null);
|
||||
const [bezig, setBezig] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [form, setForm] = useState({ schoolnaam: "", primaire_kleur: "", accent_kleur: "" });
|
||||
const [opslaanOk, setOpslaanOk] = useState(false);
|
||||
|
||||
useEffect(() => { getHuisstijl().then(setHuis).catch((e) => setError(e.message)); }, []);
|
||||
useEffect(() => { getHuisstijl().then((h) => { setHuis(h); setForm({ schoolnaam: h.schoolnaam || "", primaire_kleur: h.primaire_kleur || "", accent_kleur: h.accent_kleur || "" }); }).catch((e) => setError(e.message)); }, []);
|
||||
|
||||
async function opslaanThema() {
|
||||
setBezig(true); setError(null); setOpslaanOk(false);
|
||||
try {
|
||||
const r = await opslaanVormgeving(form);
|
||||
setHuis(r);
|
||||
_themaDirect(r);
|
||||
setOpslaanOk(true);
|
||||
} catch (e) { setError(e.message); }
|
||||
setBezig(false);
|
||||
}
|
||||
async function herstelKleuren() {
|
||||
setForm((f) => ({ ...f, primaire_kleur: "", accent_kleur: "" }));
|
||||
setBezig(true); setError(null);
|
||||
try {
|
||||
const r = await opslaanVormgeving({ ...form, primaire_kleur: "", accent_kleur: "" });
|
||||
setHuis(r);
|
||||
document.documentElement.style.removeProperty("--brand");
|
||||
document.documentElement.style.removeProperty("--brand-soft");
|
||||
document.documentElement.style.removeProperty("--accent");
|
||||
} catch (e) { setError(e.message); }
|
||||
setBezig(false);
|
||||
}
|
||||
|
||||
async function kies(e) {
|
||||
const f = e.target.files && e.target.files[0];
|
||||
|
|
@ -3316,6 +3357,21 @@ export function HuisstijlPage() {
|
|||
return (
|
||||
<div className="huisstijl">
|
||||
<Foutmelding msg={error} />
|
||||
|
||||
<h3 className="vorm-kop">Schoolnaam & kleuren</h3>
|
||||
<p className="muted small">De schoolnaam verschijnt in de balk en op afdrukken. De kleuren bepalen het thema van de hele app (knoppen, tabs, accenten).</p>
|
||||
<div className="vorm-grid">
|
||||
<label className="veld-kol"><span className="veld-kop">Schoolnaam</span><input placeholder="bv. De Regenboog" value={form.schoolnaam} onChange={(e) => setForm({ ...form, schoolnaam: e.target.value })} /></label>
|
||||
<label className="veld-kol"><span className="veld-kop">Primaire kleur</span><input type="color" value={form.primaire_kleur || "#2563eb"} onChange={(e) => setForm({ ...form, primaire_kleur: e.target.value })} /></label>
|
||||
<label className="veld-kol"><span className="veld-kop">Accentkleur</span><input type="color" value={form.accent_kleur || "#2563eb"} onChange={(e) => setForm({ ...form, accent_kleur: e.target.value })} /></label>
|
||||
</div>
|
||||
<div className="editor-actions">
|
||||
<button type="button" className="btn primary" onClick={opslaanThema} disabled={bezig}>{bezig ? "Bezig…" : "Opslaan"}</button>
|
||||
<button type="button" className="btn" onClick={herstelKleuren} disabled={bezig}>Standaardkleuren</button>
|
||||
{opslaanOk && <span className="wm-status ok">Opgeslagen ✓</span>}
|
||||
</div>
|
||||
|
||||
<h3 className="vorm-kop">Logo</h3>
|
||||
<p className="muted small">Het logo verschijnt in elke roosterweergave en op afdrukken. Gebruik bij voorkeur een <b>transparante PNG</b> of <b>SVG</b>; een logo met een gevulde (witte) achtergrond valt op een gekleurde balk lelijk op.</p>
|
||||
<div className="logo-vak">
|
||||
<div className="logo-preview" title="Voorbeeld op een geruite achtergrond (zo zie je transparantie)">
|
||||
|
|
|
|||
|
|
@ -603,6 +603,14 @@ input[type="color"] { padding: 2px; width: 44px; min-width: 44px; height: 38px;
|
|||
.logo-preview img { max-width: 200px; max-height: 104px; object-fit: contain; }
|
||||
.logo-acties { display: flex; flex-direction: column; gap: 8px; align-items: flex-start; }
|
||||
.logo-acties .btn { cursor: pointer; }
|
||||
.vorm-kop { margin: 18px 0 4px; font-size: 15px; }
|
||||
.huisstijl > .vorm-kop:first-of-type { margin-top: 0; }
|
||||
.vorm-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; align-items: end; margin: 8px 0; }
|
||||
.vorm-grid input[type="color"] { width: 64px; height: 36px; padding: 2px; cursor: pointer; }
|
||||
.brand-schoolnaam { color: var(--muted); font-weight: 600; font-size: 14px; }
|
||||
/* Accentkleur (uit de vormgeving) op een paar zichtbare plekken */
|
||||
.tab.on { border-bottom-color: var(--accent, var(--brand)); }
|
||||
.top-tab.on, .nav-trigger.on { box-shadow: inset 0 -2px 0 var(--accent, var(--brand)); }
|
||||
|
||||
/* Kleurenlegenda (alleen bij afdrukken) */
|
||||
.kleur-legenda { margin-top: 10px; padding-top: 8px; border-top: 1px solid #ccc; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue