diff --git a/ROADMAP.md b/ROADMAP.md index 0b5fee6..cbec2f6 100644 --- a/ROADMAP.md +++ b/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 diff --git a/backend/config/settings.py b/backend/config/settings.py index c36645c..5e5485b 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -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. diff --git a/backend/modules/huisstijl/apps.py b/backend/modules/huisstijl/apps.py index 7336134..ff0e417 100644 --- a/backend/modules/huisstijl/apps.py +++ b/backend/modules/huisstijl/apps.py @@ -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"), ), ) ) diff --git a/backend/modules/huisstijl/migrations/0002_thema_velden.py b/backend/modules/huisstijl/migrations/0002_thema_velden.py new file mode 100644 index 0000000..75264f9 --- /dev/null +++ b/backend/modules/huisstijl/migrations/0002_thema_velden.py @@ -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"), + ), + ] diff --git a/backend/modules/huisstijl/models.py b/backend/modules/huisstijl/models.py index e616798..7fb2215 100644 --- a/backend/modules/huisstijl/models.py +++ b/backend/modules/huisstijl/models.py @@ -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: diff --git a/backend/modules/huisstijl/serializers.py b/backend/modules/huisstijl/serializers.py index 6a368dd..016db9e 100644 --- a/backend/modules/huisstijl/serializers.py +++ b/backend/modules/huisstijl/serializers.py @@ -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"] diff --git a/backend/modules/huisstijl/views.py b/backend/modules/huisstijl/views.py index 0330bb1..fddbc66 100644 --- a/backend/modules/huisstijl/views.py +++ b/backend/modules/huisstijl/views.py @@ -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) - 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: + 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) 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 - data["kwaliteit"] = analyse + 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 "")) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f750a24..d39059c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 }) {
Roosterwijs + {schoolnaam && · {schoolnaam}}
{user.username} diff --git a/frontend/src/api.js b/frontend/src/api.js index f1b5404..9e0db59 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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(); diff --git a/frontend/src/pages.jsx b/frontend/src/pages.jsx index 4a0e309..2f8c661 100644 --- a/frontend/src/pages.jsx +++ b/frontend/src/pages.jsx @@ -14,6 +14,7 @@ import { getHuisstijl, uploadLogo, verwijderLogo, + opslaanVormgeving, } from "./api.js"; const ROLLEN = [ @@ -2830,7 +2831,7 @@ export function RoosterScherm({ mode }) {

Vrije locatie-aanduidingen (bv. gymzaal, zwembad).

- 🖼️ Logo + 🖼️ Vormgeving
@@ -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 Logo; + return ; +} + +// 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 (
+ +

Schoolnaam & kleuren

+

De schoolnaam verschijnt in de balk en op afdrukken. De kleuren bepalen het thema van de hele app (knoppen, tabs, accenten).

+
+ + + +
+
+ + + {opslaanOk && Opgeslagen ✓} +
+ +

Logo

Het logo verschijnt in elke roosterweergave en op afdrukken. Gebruik bij voorkeur een transparante PNG of SVG; een logo met een gevulde (witte) achtergrond valt op een gekleurde balk lelijk op.

diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 31086b8..116567d 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; }