diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..3ba24ff Binary files /dev/null and b/.DS_Store differ diff --git a/authentication/middleware.py b/authentication/middleware.py index 4cfe4af..8cbe9ac 100644 --- a/authentication/middleware.py +++ b/authentication/middleware.py @@ -17,7 +17,9 @@ from django.urls import reverse from club.models import ClubRole #: Paths a not-yet-enrolled user must still reach (to enrol, or to log out). -EXEMPT_PREFIXES = ("/accounts/", "/static/", "/media/") +#: ``/__reload__/`` is django-browser-reload's event stream, which only exists +#: under DEBUG — without it, live reload dies on the enrolment page itself. +EXEMPT_PREFIXES = ("/accounts/", "/static/", "/media/", "/__reload__/") ELEVATED_ROLES = (ClubRole.Roles.ADMIN, ClubRole.Roles.EDITOR) diff --git a/pyproject.toml b/pyproject.toml index fbfc6cc..7fa16ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ [dependency-groups] dev = [ "coverage>=7.15.0", + "django-browser-reload>=1.21.0", "ruff>=0.15.17", ] diff --git a/rosterchief/settings.py b/rosterchief/settings.py index fb1411c..fdd3375 100644 --- a/rosterchief/settings.py +++ b/rosterchief/settings.py @@ -81,6 +81,13 @@ MIDDLEWARE = [ "django.middleware.clickjacking.XFrameOptionsMiddleware", ] +# Reload the browser when templates, static files or Python change. Dev only: it +# injects a script tag into every HTML response and serves an open event stream, +# neither of which belongs in production. +if DEBUG: + INSTALLED_APPS += ["django_browser_reload"] + MIDDLEWARE += ["django_browser_reload.middleware.BrowserReloadMiddleware"] + AUTHENTICATION_BACKENDS = [ "django.contrib.auth.backends.ModelBackend", "allauth.account.auth_backends.AuthenticationBackend", diff --git a/rosterchief/tests.py b/rosterchief/tests.py new file mode 100644 index 0000000..2dcf681 --- /dev/null +++ b/rosterchief/tests.py @@ -0,0 +1,30 @@ +import importlib + +from django.test import SimpleTestCase, override_settings +from django.urls import Resolver404, clear_url_caches, resolve + +from . import urls + + +class BrowserReloadUrlTests(SimpleTestCase): + """django-browser-reload serves an open event stream and injects a script into + every HTML response, so it must never be mounted outside DEBUG.""" + + def reload_urlconf(self): + importlib.reload(urls) + clear_url_caches() + + def tearDown(self): + self.reload_urlconf() # restore the real (DEBUG=False) urlconf + + def test_the_reload_endpoint_is_mounted_in_debug(self): + with override_settings(DEBUG=True): + self.reload_urlconf() + + self.assertEqual(resolve("/__reload__/events/").view_name, "django_browser_reload:events") + + def test_the_reload_endpoint_is_absent_without_debug(self): + self.reload_urlconf() + + with self.assertRaises(Resolver404): + resolve("/__reload__/events/") diff --git a/rosterchief/urls.py b/rosterchief/urls.py index 15fb04f..c743dff 100644 --- a/rosterchief/urls.py +++ b/rosterchief/urls.py @@ -7,6 +7,7 @@ second factors. ``RequireMFAMiddleware`` then blocks any staff user who has not enrolled. """ +from django.conf import settings from django.contrib import admin from django.urls import include, path from django.views.generic import RedirectView @@ -17,3 +18,6 @@ urlpatterns = [ path("accounts/", include("allauth.urls")), path("controlpanel/", include("controlpanel.urls")), ] + +if settings.DEBUG: + urlpatterns += [path("__reload__/", include("django_browser_reload.urls"))] diff --git a/static/.DS_Store b/static/.DS_Store new file mode 100644 index 0000000..bff4bec Binary files /dev/null and b/static/.DS_Store differ diff --git a/static/css/app.css b/static/css/app.css index b421c9f..55fecd1 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -27,6 +27,10 @@ --font-weight-medium: 500; --font-weight-semibold: 600; --font-weight-bold: 700; + --ease-out: cubic-bezier(0, 0, 0.2, 1); + --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + --default-transition-duration: 150ms; + --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); --default-font-family: var(--font-sans); --default-mono-font-family: var(--font-mono); } @@ -464,6 +468,26 @@ } } } + .collapse-plus { + @layer daisyui.l1.l2 { + > .collapse-title:after { + position: absolute; + display: block; + height: 0.5rem; + width: 0.5rem; + @media (prefers-reduced-motion: no-preference) { + transition-property: all; + transition-duration: 300ms; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + } + top: 0.9rem; + inset-inline-end: 1.4rem; + --tw-content: "+"; + content: var(--tw-content); + pointer-events: none; + } + } + } .dropdown { @layer daisyui.l1.l2.l3 { position: relative; @@ -917,6 +941,21 @@ } } } + .collapse-open { + @layer daisyui.l1.l2 { + grid-template-rows: max-content 1fr; + > .collapse-content { + --overflow-delay: 0.2s; + overflow: revert-layer; + content-visibility: visible; + min-height: fit-content; + padding-bottom: 1rem; + @supports not (content-visibility: visible) { + visibility: visible; + } + } + } + } .collapse { visibility: collapse; } @@ -982,6 +1021,27 @@ } } } + .toast { + @layer daisyui.l1.l2.l3 { + position: fixed; + inset-inline-start: auto; + inset-inline-end: calc(0.25rem * 4); + top: auto; + bottom: calc(0.25rem * 4); + display: flex; + flex-direction: column; + gap: calc(0.25rem * 2); + background-color: transparent; + translate: var(--toast-x, 0) var(--toast-y, 0); + width: max-content; + max-width: calc(100vw - 2rem); + & > * { + @media (prefers-reduced-motion: no-preference) { + animation: toast 0.25s ease-out; + } + } + } + } .toggle { @layer daisyui.l1.l2.l3 { border: var(--border) solid currentColor; @@ -1318,6 +1378,51 @@ } } } + .aura { + @layer daisyui.l1.l2.l3 { + position: relative; + display: inline-block; + --aura-padding: 0.125rem; + padding: var(--aura-padding); + border-radius: calc(var(--aura-padding) + var(--aura-radius, var(--radius-box))); + animation: aura var(--tw-duration, 6s) linear infinite; + background-image: conic-gradient(from var(--aura-angle), transparent 225deg, currentColor); + &:has( > .card, > .alert) { + --aura-radius: var(--radius-box); + } + &:has( > .btn, > .input, > .select) { + --aura-radius: var(--radius-field); + } + &:has( > .checkbox, > .toggle, > .badge) { + --aura-radius: var(--radius-selector); + } + &:before, &:after { + animation: inherit; + background-color: inherit; + background-image: inherit; + border-radius: inherit; + position: absolute; + top: calc(1 / 2 * 100%); + left: calc(1 / 2 * 100%); + z-index: 0; + display: block; + opacity: 70%; + filter: blur(0.25rem); + translate: -50% -50%; + width: 100%; + height: 100%; + content: ""; + } + &:after { + opacity: 30%; + filter: blur(1rem); + } + & > * { + position: relative; + z-index: 1; + } + } + } .select { @layer daisyui.l1.l2.l3 { position: relative; @@ -1666,6 +1771,48 @@ } } } + .rating { + @layer daisyui.l1.l2.l3 { + position: relative; + display: inline-flex; + vertical-align: middle; + --size: var(--size-selector, 0.25rem) * 6; + input { + cursor: pointer; + appearance: none; + } + * { + border-radius: 0; + background-color: var(--color-base-content); + opacity: 20%; + width: calc(var(--size) * 1); + height: calc(var(--size)); + @media (prefers-reduced-motion: no-preference) { + animation: rating 0.25s ease-out; + } + } + .rating-hidden { + width: calc(0.25rem * 2); + background-color: transparent; + } + :checked, [aria-checked="true"], [aria-current="true"], :has( ~ :checked, ~ [aria-checked="true"], ~ [aria-current="true"]) { + opacity: 100%; + } + :focus-visible { + scale: 1.1; + @media (prefers-reduced-motion: no-preference) { + transition: scale 0.2s ease-out; + } + } + :active:focus { + animation: none; + scale: 1.1; + } + } + @layer daisyui.l1.l2 { + --size: var(--size-selector, 0.25rem) * 6; + } + } .navbar { @layer daisyui.l1.l2.l3 { display: flex; @@ -1734,9 +1881,89 @@ border-radius: var(--radius-box); } } + .progress { + @layer daisyui.l1.l2.l3 { + position: relative; + height: calc(0.25rem * 2); + width: 100%; + appearance: none; + overflow: hidden; + border-radius: var(--radius-box); + background-color: currentcolor; + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, currentcolor 20%, transparent); + } + color: var(--color-base-content); + &:indeterminate { + background-image: repeating-linear-gradient( 90deg, currentColor -1%, currentColor 10%, #0000 10%, #0000 90% ); + background-size: 200%; + background-position-x: 15%; + @media (prefers-reduced-motion: no-preference) { + animation: progress 5s ease-in-out infinite; + } + @supports (-moz-appearance: none) { + &::-moz-progress-bar { + background-color: transparent; + @media (prefers-reduced-motion: no-preference) { + animation: progress 5s ease-in-out infinite; + background-image: repeating-linear-gradient( 90deg, currentColor -1%, currentColor 10%, #0000 10%, #0000 90% ); + background-size: 200%; + background-position-x: 15%; + } + } + } + } + @supports (-moz-appearance: none) { + &::-moz-progress-bar { + border-radius: var(--radius-box); + background-color: currentcolor; + @media (prefers-reduced-motion: no-preference) { + transition: inline-size 0.3s ease; + } + } + } + @supports (-webkit-appearance: none) { + &::-webkit-progress-bar { + border-radius: var(--radius-box); + background-color: transparent; + } + &::-webkit-progress-value { + border-radius: var(--radius-box); + background-color: currentColor; + @media (prefers-reduced-motion: no-preference) { + transition: inline-size 0.3s ease; + } + } + } + } + } .static { position: static; } + .dropdown-right { + @layer daisyui.l1.l2 { + --anchor-h: right; + --anchor-v: span-bottom; + .dropdown-content { + inset-inline-start: 100%; + top: 0; + bottom: auto; + transform-origin: 0; + } + } + } + .dropdown-left { + @layer daisyui.l1.l2 { + --anchor-h: left; + --anchor-v: span-bottom; + .dropdown-content { + inset-inline-end: 100%; + top: 0; + bottom: auto; + transform-origin: 100%; + } + } + } .dropdown-end { @layer daisyui.l1.l2 { --anchor-h: span-left; @@ -1931,6 +2158,26 @@ .z-10 { z-index: 10; } + .tab-content { + @layer daisyui.l1.l2.l3 { + order: var(--tabcontent-order); + display: none; + border-color: transparent; + --tabcontent-radius-ss: var(--radius-box); + --tabcontent-radius-se: var(--radius-box); + --tabcontent-radius-es: var(--radius-box); + --tabcontent-radius-ee: var(--radius-box); + --tabcontent-order: 1; + width: 100%; + height: calc(100% - var(--tab-height) + var(--border)); + margin: var(--tabcontent-margin); + border-width: var(--border); + border-start-start-radius: var(--tabcontent-radius-ss); + border-start-end-radius: var(--tabcontent-radius-se); + border-end-start-radius: var(--tabcontent-radius-es); + border-end-end-radius: var(--tabcontent-radius-ee); + } + } .hero { @layer daisyui.l1.l2.l3 { display: grid; @@ -2328,6 +2575,46 @@ .flex-none { flex: none; } + .flex-shrink { + flex-shrink: 1; + } + .flex-grow { + flex-grow: 1; + } + .border-collapse { + border-collapse: collapse; + } + .transform { + transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); + } + .skeleton { + @layer daisyui.l1.l2.l3 { + border-radius: var(--radius-box); + background-color: var(--color-base-300); + @media (prefers-reduced-motion: reduce) { + transition-duration: 15s; + } + will-change: background-position; + background-image: linear-gradient( 105deg, #0000 0% 40%, var(--color-base-100) 50%, #0000 60% 100% ); + background-size: 200% auto; + background-position-x: -50%; + @media (prefers-reduced-motion: no-preference) { + animation: skeleton 1.8s ease-in-out infinite; + } + } + } + .aura-glow { + @layer daisyui.l1.l2 { + animation: none; + background-image: radial-gradient(closest-corner at center, currentColor 0%, transparent 90%); + &:before { + animation: aura-glow var(--tw-duration, 6s) ease-out infinite; + } + &:after { + animation: aura-glow-after var(--tw-duration, 6s) ease-out infinite; + } + } + } .link { @layer daisyui.l1.l2.l3 { cursor: pointer; @@ -2349,6 +2636,9 @@ .cursor-pointer { cursor: pointer; } + .resize { + resize: both; + } .flex-wrap { flex-wrap: wrap; } @@ -2435,6 +2725,9 @@ .bg-base-200 { background-color: var(--color-base-200); } + .bg-base-300 { + background-color: var(--color-base-300); + } .btn-link { @layer daisyui { text-decoration-line: underline; @@ -2553,6 +2846,9 @@ --alert-color: var(--color-warning); } } + .text-base-content { + color: var(--color-base-content); + } .text-base-content\/70 { color: var(--color-base-content); @supports (color: color-mix(in lab, red, red)) { @@ -2587,6 +2883,9 @@ text-decoration-line: none; } } + .underline { + text-decoration-line: underline; + } .opacity-60 { opacity: 60%; } @@ -2601,9 +2900,26 @@ --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } + .outline { + outline-style: var(--tw-outline-style); + outline-width: 1px; + } .filter { filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } + .transition { + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .ease-in-out { + --tw-ease: var(--ease-in-out); + transition-timing-function: var(--ease-in-out); + } + .ease-out { + --tw-ease: var(--ease-out); + transition-timing-function: var(--ease-out); + } .btn-error { @layer daisyui.l1.l2 { --btn-color: var(--color-error); @@ -2683,6 +2999,12 @@ } } } +[data-theme="light"] .logo { + content: var(--logo-dark); +} +[data-theme="dark"] .logo { + content: var(--logo-light); +} @layer base { :where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light] { color-scheme: light; @@ -2969,6 +3291,26 @@ opacity: 0; } } +@property --tw-rotate-x { + syntax: "*"; + inherits: false; +} +@property --tw-rotate-y { + syntax: "*"; + inherits: false; +} +@property --tw-rotate-z { + syntax: "*"; + inherits: false; +} +@property --tw-skew-x { + syntax: "*"; + inherits: false; +} +@property --tw-skew-y { + syntax: "*"; + inherits: false; +} @property --tw-space-y-reverse { syntax: "*"; inherits: false; @@ -3073,6 +3415,11 @@ inherits: false; initial-value: 0 0 #0000; } +@property --tw-outline-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} @property --tw-blur { syntax: "*"; inherits: false; @@ -3126,9 +3473,18 @@ syntax: "*"; inherits: false; } +@property --tw-ease { + syntax: "*"; + inherits: false; +} @layer properties { @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { *, ::before, ::after, ::backdrop { + --tw-rotate-x: initial; + --tw-rotate-y: initial; + --tw-rotate-z: initial; + --tw-skew-x: initial; + --tw-skew-y: initial; --tw-space-y-reverse: 0; --tw-divide-y-reverse: 0; --tw-border-style: solid; @@ -3152,6 +3508,7 @@ --tw-ring-offset-width: 0px; --tw-ring-offset-color: #fff; --tw-ring-offset-shadow: 0 0 #0000; + --tw-outline-style: solid; --tw-blur: initial; --tw-brightness: initial; --tw-contrast: initial; @@ -3165,6 +3522,7 @@ --tw-drop-shadow-color: initial; --tw-drop-shadow-alpha: 100%; --tw-drop-shadow-size: initial; + --tw-ease: initial; } } } diff --git a/static/images/rosterchief-dark.svg b/static/images/rosterchief-dark.svg new file mode 100644 index 0000000..d58f99f --- /dev/null +++ b/static/images/rosterchief-dark.svg @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/static/images/rosterchief-white.svg b/static/images/rosterchief-white.svg new file mode 100644 index 0000000..12f0bfb --- /dev/null +++ b/static/images/rosterchief-white.svg @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/static/src/app.css b/static/src/app.css index bc76a63..3389bfe 100644 --- a/static/src/app.css +++ b/static/src/app.css @@ -8,5 +8,13 @@ /* daisyUI: light is the default, dark applies automatically when the OS asks for it. An explicit data-theme on (set by the toggle) overrides both. */ @plugin "daisyui" { - themes: light --default, dark --prefersdark; + themes: light --default, dark --prefersdark; +} + +[data-theme="light"] .logo { + content: var(--logo-dark); +} + +[data-theme="dark"] .logo { + content: var(--logo-light); } diff --git a/templates/_controlpanel_base.html b/templates/_controlpanel_base.html new file mode 100644 index 0000000..02581df --- /dev/null +++ b/templates/_controlpanel_base.html @@ -0,0 +1,33 @@ +{% load static lucide %} + + + +
+ + + +