diff --git a/server/static/css/admin_extra.css b/server/static/css/admin_extra.css index d9b2e8f..687965e 100644 --- a/server/static/css/admin_extra.css +++ b/server/static/css/admin_extra.css @@ -1,4 +1,60 @@ /** Don't show inline item names; it's visual clutter. */ .tabular.inline-related td.original p { visibility: hidden +} + + + +/**--------------------------------- + * Logo Specimen (Django admin view) + *---------------------------------*/ + +logo-specimen { + display: flex; + gap: 0.5rem; +} + +logo-specimen .bubble { + display: flex; + align-items: center; + overflow: hidden; + width: 48px; + height: 48px; + background-color: var(--logo-bg-color); +} + +logo-specimen .bubble img { + display: block; + margin: 0 auto; + max-width: 80%; + max-height: 80%; +} + +logo-specimen .bg { + display: flex; + font-weight: 600; + align-items: center; + justify-content: center; + padding-left: 0.5rem; + padding-right: 0.5rem; + background-color: var(--logo-bg-color); + color: var(--logo-bg-text-color); +} + +logo-specimen .action { + cursor: pointer; + display: flex; + font-weight: 600; + align-items: center; + justify-content: center; + padding-left: 0.5rem; + padding-right: 0.5rem; + transition: opacity 0.2s; + background-color: var(--logo-action-color); + color: var(--logo-action-text-color); +} + +logo-specimen .action:hover { + opacity: 0.7; + transition: opacity 0.2s ease-in-out; } \ No newline at end of file diff --git a/server/static/css/base.css b/server/static/css/base.css deleted file mode 100644 index 0d604e5..0000000 --- a/server/static/css/base.css +++ /dev/null @@ -1,69 +0,0 @@ -:root { - --font-sans: 'Nippo', sans-serif; - --font-mono: 'KodeMono', monospace; - - --lime: #CDFF64; - --white-70: rgba(255, 255, 255, 0.7); -} - -html { - font-size: 16px; - font-family: var(--font-sans); -} - - -/** -* This is a variable font -* You can controll variable axes as shown below: -* font-variation-settings: 'wght' 700.0; -* -* available axes: - -* 'wght' (range from 200.0 to 700.0) - -*/ -@font-face { - font-family: 'Nippo'; - src: url('../fonts/Nippo-Variable.woff2') format('woff2'), - url('../fonts/Nippo-Variable.woff') format('woff'), - url('../fonts/Nippo-Variable.ttf') format('truetype'); - font-weight: 200 700; - font-display: swap; - font-style: normal; -} - - -@font-face { - font-family: 'KodeMono'; - src: url('../fonts/KodeMono-Medium.ttf') format('truetype'); - font-weight: 500; - font-display: swap; - font-style: normal; -} - - -/** Further reset; why doesn't modern-normalize do this? */ - -h1, -h2, -h3, -h4, -h5, -h6 { - margin: 0; -} - -/* All containers behave this way. */ -.container { - margin-left: 0.5rem; - margin-right: 0.5rem; -} - - -/** md is 768px */ -@media screen and (min-width: 768px) { - .container { - max-width: 640px; - margin: 0 auto; - } -} \ No newline at end of file diff --git a/server/static/css/voterbowl.css b/server/static/css/voterbowl.css new file mode 100644 index 0000000..b37e7e1 --- /dev/null +++ b/server/static/css/voterbowl.css @@ -0,0 +1,805 @@ +@import url('./modern-normalize.min.css'); + + +/**-------------------------------- + * Fonts + *--------------------------------*/ + + +@font-face { + font-family: 'Nippo'; + src: url('../fonts/Nippo-Variable.woff2') format('woff2'), + url('../fonts/Nippo-Variable.woff') format('woff'), + url('../fonts/Nippo-Variable.ttf') format('truetype'); + font-weight: 200 700; + font-display: swap; + font-style: normal; +} + + +@font-face { + font-family: 'KodeMono'; + src: url('../fonts/KodeMono-Medium.ttf') format('truetype'); + font-weight: 500; + font-display: swap; + font-style: normal; +} + + +/**-------------------------------- + * Site-wide CSS + *--------------------------------*/ + +:root { + --font-sans: 'Nippo', sans-serif; + --font-mono: 'KodeMono', monospace; + + /** + All other variables are set with css_vars(...) + since our site dynamically changes colors depending + on the school selected. + */ +} + +html { + font-size: 16px; + font-family: var(--font-sans); + background-color: var(--bg-color); +} + +#faq { + width: 100%; + color: white; + padding: 2rem 0; + background-color: black; +} + + +/** Further reset; why doesn't modern-normalize do this? */ +h1, +h2, +h3, +h4, +h5, +h6 { + margin: 0; +} + +/* Our default container behavior for mobile. */ +.container { + margin-left: 0.5rem; + margin-right: 0.5rem; +} + +/* Our default container behavior for larger screens. */ +@media screen and (min-width: 768px) { + .container { + max-width: 640px; + margin: 0 auto; + } +} + + + +/**-------------------------------- + * button.py + *--------------------------------*/ + +.button { + cursor: pointer; + transition: opacity 0.2s ease-in-out; + text-transform: uppercase; + text-decoration: none; + font-weight: 600; + font-size: 18px; + line-height: 100%; + border: none; + text-align: center; + letter-spacing: 0.05em; + padding: 20px 24px; + background-color: var(--bg-color); + color: var(--color); +} + +.button:hover { + opacity: 0.7; + transition: opacity 0.2s ease-in-out; +} + + + +/**-------------------------------- + * countdown.py & BigCountdown + *--------------------------------*/ + +big-countdown { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding-bottom: 0.5rem; +} + +big-countdown p { + text-transform: uppercase; +} + +big-countdown .countdown { + display: flex; + justify-content: center; + align-items: center; + font-size: 24px; + font-weight: 500; + font-family: var(--font-mono); + gap: 4px; + height: 34px !important; +} + +big-countdown .countdown span { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + width: 27px; +} + +big-countdown .countdown span.number { + color: var(--number-color); + background-color: var(--number-bg-color); +} + +big-countdown .countdown span.colon { + color: var(--colon-color); + background-color: transparent; +} + + +/**-------------------------------- + * Footer + *--------------------------------*/ + +footer { + background-color: black; + color: #aaa; + padding-top: 4rem; + padding-bottom: 2rem; + padding-left: 0.5rem; + padding-right: 0.5rem; + width: 100%; +} + +@media screen and (min-width: 768px) { + footer { + padding-left: 2em; + padding-right: 2rem; + } +} + +footer div.center { + margin-bottom: 2em; + display: flex; + justify-content: center; + color: #fff; +} + +footer div.center svg { + width: 120px !important; +} + +footer div.outer { + display: flex; + flex-direction: column-reverse; + justify-content: space-between; + align-items: center; +} + +@media screen and (min-width: 768px) { + footer div.outer { + flex-direction: row; + } +} + +footer div.inner { + display: flex; + flex-direction: row; + gap: 1em; +} + +footer a { + color: #aaa; + text-decoration: underline; +} + +footer a:hover { + color: white; +} + +footer .colophon { + text-align: center; + color: #888; + font-size: 0.8em; + padding-top: 1em; + padding-bottom: 3em; +} + + +/**-------------------------------- + * FAQ + *--------------------------------*/ + +#faq { + display: flex; + flex-direction: column; +} + +#faq h2 { + font-size: 36px; + font-weight: 440; + line-height: 130%; + margin-bottom: 1rem; +} + +#faq h3 { + font-weight: 600; + font-size: 18px; + line-height: 28px; + margin-top: 1rem; +} + +#faq p { + font-weight: 378; + font-size: 18px; + line-height: 28px; + opacity: 0.7; +} + +#faq a { + color: white; + cursor: pointer; + text-decoration: underline; + transition: opacity 0.2s; +} + +#faq a:hover { + opacity: 0.7; + transition: opacity 0.2s; +} + + +/**-------------------------------- + * After Email Validation Page + *--------------------------------*/ + +#validate-email { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; +} + +#validate-email a { + color: var(--main-color); + text-decoration: underline; + transition: opacity 0.2s; +} + +#validate-email a:hover { + opacity: 0.7; + transition: opacity 0.2s; +} + +#validate-email main { + width: 100%; + text-align: center; + padding: 2rem 0; +} + +#validate-email main img { + height: 150px; + margin: 1.5rem 0; +} + +#validate-email main p { + font-weight: 378; + font-size: 20px; + line-height: 130%; +} + +#validate-email main h2 { + font-weight: 500; + font-size: 36px; + line-height: 120%; + text-transform: uppercase; +} + +#validate-email .faq { + width: 100%; + color: white; + background-color: black; + padding: 2rem 0; +} + +#validate-email .button-holder { + display: flex; + justify-content: center; + margin: 1.5rem 0; +} + +#validate-email main { + color: var(--main-color); + background-color: var(--main-bg-color); +} + +#validate-email main h2 { + display: flex; + justify-content: center; + align-items: center; +} + +#validate-email main .hidden { + display: none; +} + +#validate-email main .code { + font-size: 0.75em; +} + +#validate-email main .clipboard, +#validate-email main .copied { + margin-left: 0.2em; + margin-top: 0.05em; + width: 0.75em; +} + +#validate-email main .clipboard { + opacity: 0.5; + cursor: pointer; + transition: opacity 0.2s; +} + +#validate-email main .clipboard:hover { + opacity: 1; + transition: opacity 0.2s; +} + +#validate-email main .copied { + opacity: 0.5; +} + +@media screen and (min-width: 768px) { + + #validate-email main .clipboard, + #validate-email main .copied { + margin-left: 0.2em; + margin-top: 0.2em; + width: 1em; + } + + #validate-email main .code { + font-size: 1em; + } +} + + +/**-------------------------------- + * Upcoming Contest Partial + *--------------------------------*/ + +.upcoming-contest { + border: 3px solid black; + padding: 1rem; + color: black; + font-size: 18px; + font-weight: 440; + font-variation-settings: "wght" 440; + line-height: 1; +} + +.upcoming-contest .content { + display: flex; + align-items: center; + gap: 1em; +} + +.upcoming-contest .logo { + border-radius: 100%; + border: 2px solid black; + background-color: var(--logo-bg-color); + overflow: hidden; + width: 36px; + height: 36px; +} + +.upcoming-contest .logo img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.upcoming-contest p { + margin: 0; +} + + +/**-------------------------------- + * Ongoing Contest Partial + *--------------------------------*/ + +.ongoing-contest { + border: 3px solid black; + color: black; + font-weight: 400; + font-size: 18px; + line-height: 140%; + padding-left: 1em; + padding-right: 1em; + position: relative; +} + +.ongoing-contest .content { + display: flex; + flex-direction: column; +} + +.ongoing-contest .logo { + border-radius: 100%; + border: 2px solid black; + background-color: var(--logo-bg-color); + overflow: hidden; + width: 60px; + height: 60px; + margin: 1.5em auto 1em auto; +} + +.ongoing-contest .logo img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.ongoing-contest .school { + margin: 0; + font-weight: 500; + font-size: 24px; + line-height: 100%; + display: flex; + justify-content: center; +} + +.ongoing-contest .description { + margin-bottom: 0; +} + +.ongoing-contest .button-holder { + width: 100%; +} + +.ongoing-contest .button-holder a { + width: 100%; +} + +/* A centered box at the top of the card */ +.ongoing-contest .box { + position: absolute; + top: -1em; + left: 50%; + transform: translateX(-50%); + border: 3px solid black; + background-color: #cdff64; + font-weight: 600; + line-height: 100%; + letter-spacing: 4%; + min-width: 70%; + padding: 0.25rem; + text-transform: uppercase; +} + +@media screen and (min-width: 768px) { + .ongoing-contest .box { + min-width: 35%; + } +} + + + +/**-------------------------------- + * Home Page + *--------------------------------*/ + +#home-page { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + background-color: #cdff64; + color: black; +} + +#home-page main { + width: 100%; + text-align: center; + padding: 2rem 0; +} + +#home-page main svg { + width: 104px; + margin: 1.5rem 0; +} + +@media screen and (min-width: 768px) { + #home-page main svg { + width: 112px; + } +} + +#home-page main p { + font-weight: 378; + font-size: 20px; + line-height: 130%; +} + +#home-page main h2 { + font-weight: 500; + font-size: 28px; + line-height: 140%; +} + +@media screen and (min-width: 768px) { + #home-page main h2 { + font-size: 32px; + } +} + +#home-page .button-holder { + display: flex; + justify-content: center; + margin: 1.5rem 0; +} + +#home-page .ongoing { + display: flex; + flex-direction: column; + justify-content: center; + gap: 2rem; + margin: 2rem 0; +} + +#home-page .upcoming { + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.5rem; + margin: 0.5rem 0; +} + +#home-page .coming-soon { + text-transform: uppercase; + font-weight: bold; + font-size: 20px; + line-height: 130%; + display: flex; + justify-content: center; + margin: 1.5rem 0; +} + + +/**-------------------------------- + * School Landing Page + *--------------------------------*/ + +#school-page { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; +} + +#school-page main { + width: 100%; + text-align: center; + padding-bottom: 2rem; + color: var(--color); + background-color: var(--bg-color); +} + +#school-page main>div { + display: flex; + flex-direction: column; + justify-content: space-evenly; + min-height: calc(100dvh - env(safe-area-inset-bottom) - 2rem); +} + +@media screen and (min-width: 768px) { + #school-page main>div { + padding: 2rem 0; + min-height: unset; + } +} + +#school-page main img { + height: 150px; + margin: 1.5rem 0; +} + +#school-page main p { + font-weight: 378; + font-size: 20px; + line-height: 130%; +} + +#school-page main h2 { + font-weight: 500; + font-size: 36px; + line-height: 120%; + text-transform: uppercase; +} + +#school-page .faq { + width: 100%; + color: white; + padding: 2rem 0; +} + +#school-page .button-holder { + display: flex; + justify-content: center; + margin: 0; +} + +@media screen and (min-width: 768px) { + #school-page .button-holder { + margin-top: 1.5rem; + } +} + +#school-page .faq { + background-color: black; +} + +/**-------------------------------- + * Rules Page + *--------------------------------*/ + +#rules-page { + font-size: 1.25em; + line-height: 150%; + font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; +} + +/**-------------------------------- + * Check Page + *--------------------------------*/ + +check-page { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; +} + +check-page main { + width: 100%; + text-align: center; + padding: 0.5rem 0; +} + +check-page main img { + height: 150px; + margin-bottom: -1.75rem; +} + +check-page main p { + font-weight: 378; + font-size: 20px; + line-height: 130%; +} + +check-page main h2 { + font-weight: 500; + font-size: 36px; + line-height: 120%; + text-transform: uppercase; +} + +check-page .faq { + width: 100%; + color: white; + padding: 2rem 0; +} + +check-page .button-holder { + display: flex; + justify-content: center; + margin: 1.5rem 0; +} + +check-page .form { + width: 100%; + background-color: white; + padding: 2rem 0; +} + +check-page .urgency { + flex-direction: column; + gap: 1rem; +} + +@media screen and (min-width: 768px) { + check-page main { + padding: 2rem 0; + } + + check-page main img { + height: 150px; + margin: 1.5rem 0; + } + + check-page .urgency { + flex-direction: row; + gap: 2rem; + } +} + +check-page main { + position: relative; + color: var(--main-color); + background-color: var(--main-bg-color); +} + +check-page main a { + color: var(--main-color); + transition: opacity 0.2s; +} + +check-page main a:hover { + opacity: 0.7; + transition: opacity 0.2s; +} + +check-page main .urgency { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +check-page main .fireworks { + pointer-events: none; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + overflow: hidden; +} + +check-page main .separate { + padding-left: 1rem; +} + +check-page main img { + display: block; +} + +@media screen and (min-width: 768px) { + check-page main .urgency { + flex-direction: row; + } +} + + +/**-------------------------------- + * Finish Check Partial + *--------------------------------*/ + +finish-check>p { + padding-top: 1rem; + margin-left: 0; +} + +@media screen and (min-width: 768px) { + finish-check>p { + padding-top: 0; + margin-left: 1rem; + } +} \ No newline at end of file diff --git a/server/utils/components.py b/server/utils/components.py index 40273d9..a6caaea 100644 --- a/server/utils/components.py +++ b/server/utils/components.py @@ -1,84 +1,100 @@ """Utilities for working with HTML-in-python components.""" -import json import pathlib import typing as t +from dataclasses import dataclass, field, replace import htpy as h import markdown +from htpy import _iter_children as _h_iter_children from markupsafe import Markup -from pydantic.alias_generators import to_camel -def load_file(file_name: str | pathlib.Path) -> str: +def _load_file(file_name: str | pathlib.Path) -> str: """Load a text file and return its contents.""" with open(file_name, "r") as f: return f.read() -def load_sibling_file(base_file_name: str | pathlib.Path, file_name: str) -> str: +def _load_sibling_file(base_file_name: str | pathlib.Path, file_name: str) -> str: """Load a file in the same directory as the base file.""" - return load_file(pathlib.Path(base_file_name).resolve().parent / file_name) + return _load_file(pathlib.Path(base_file_name).resolve().parent / file_name) -def _css_vars(selector: str, /, **vars: str) -> str: - """Generate CSS variables to inject into a stylesheet.""" - as_css = "\n".join(f" --{k.replace('_', '-')}: {v};" for k, v in vars.items()) - return f"{selector} {{\n{as_css}\n}}\n" +def svg(base_file_name: str | pathlib.Path, file_name: str) -> Markup: + """Load an SVG file in the same directory as the base file.""" + return Markup(_load_sibling_file(base_file_name, file_name)) -def style(base_file_name: str | pathlib.Path, file_name: str, **vars: str) -> h.Element: - """ - Load a CSS file in the same directory as the base file. +def markdown_html(base_file_name: str | pathlib.Path, file_name: str) -> Markup: + """Load a markdown file in the same directory as the base file.""" + text = _load_sibling_file(base_file_name, file_name) + return Markup(markdown.markdown(text)) - In addition to the file, you can pass in CSS variables to inject into the - stylesheet. - """ - text = load_sibling_file(base_file_name, file_name) - if vars: - text = _css_vars("me", **vars) + text - return h.style[Markup(text)] - - -def js( - base_file_name: str | pathlib.Path, - file_name: str, - *, - surreal: bool = True, - **props: t.Any, -) -> h.Element: - """ - Load a JS file in the same directory as the base file. - Return a script element. +def css_vars(**vars: str) -> str: + """Generate CSS variables to inject into an inline style attribute.""" + return " ".join(f"--{k.replace('_', '-')}: {v};" for k, v in vars.items()) + + +# FUTURE use PEP 695 syntax when mypy supports it +P = t.ParamSpec("P") +C = t.TypeVar("C") +R = t.TypeVar("R", h.Element, h.Node) + + +@dataclass(frozen=True) +class with_children(t.Generic[C, P, R]): + """Wrap a function to make it look more like an htpy.Element.""" + + _f: t.Callable[t.Concatenate[C, P], R] + _args: tuple[t.Any, ...] = field(default_factory=tuple) + _kwargs: t.Mapping[str, t.Any] = field(default_factory=dict) - If `props` are provided, they are added to the script element - as data-props JSON. + def __getitem__(self, children: C) -> R: + """Render the component with the given children.""" + return self._f(children, *self._args, **self._kwargs) # type: ignore - The `surreal` flag, if True, causes us to wrap the provided javascript - in an invocation wrapper. The javascript is expected to take - two arguments: `self` and `props`. + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> t.Self: + """Return a new instance of the class with the given arguments.""" + return replace(self, _args=args, _kwargs=kwargs) - CONSIDER: this still feels awkward to me, and I bet there's a cleaner - pattern -- our CSS pattern feels very clean to me, for instance. + def __str__(self) -> str: + """Return the name of the function being wrapped.""" + # CONSIDER: alternatively, invoke the function here? If the function + # provides a default value for its arguments, it'll work; otherwise, + # it will blow up... which might be a good thing? + return f"with_children[{self._f.__name__}]" + + +class Fragment: """ - text = load_sibling_file(base_file_name, file_name) - element = h.script - if props: - as_camel = {to_camel(k): v for k, v in props.items()} - as_json = json.dumps(as_camel) - element = element(data_props=as_json) - if surreal: - text = f"({text})(me(), me().querySelector('script').dataset.props && JSON.parse(me().querySelector('script').dataset.props))" # noqa: E501 - return element[Markup(text)] + A fragment of HTML with no explicit parent element. + CONSIDER: this feels like it should perhaps be the base class from + which htpy.Element inherits? It's a container for children, without + an Element's tag/attributes. And htpy.Node should probably include + this as well. + """ -def svg(base_file_name: str | pathlib.Path, file_name: str) -> Markup: - """Load an SVG file in the same directory as the base file.""" - return Markup(load_sibling_file(base_file_name, file_name)) + __slots__ = ("_children",) + def __init__(self, children: h.Node) -> None: + """Initialize the fragment with the given children.""" + self._children = children -def markdown_html(base_file_name: str | pathlib.Path, file_name: str) -> Markup: - """Load a markdown file in the same directory as the base file.""" - text = load_sibling_file(base_file_name, file_name) - return Markup(markdown.markdown(text)) + def __getitem__(self, children: h.Node) -> t.Self: + """Return a new fragment with the given children.""" + return self.__class__(children) + + def __str__(self) -> Markup: + """Return the fragment as a string.""" + return Markup("".join(str(x) for x in self)) + + def __iter__(self): + """Iterate over the children of the fragment.""" + # XXX I'm using a private method here, which is not ideal. + yield from _h_iter_children(self._children) + + +fragment = Fragment(None) diff --git a/server/vb/components/base_page.css b/server/vb/components/base_page.css deleted file mode 100644 index f2da01b..0000000 --- a/server/vb/components/base_page.css +++ /dev/null @@ -1,10 +0,0 @@ -html { - background-color: var(--bg-color); -} - -.faq { - width: 100%; - color: white; - padding: 2rem 0; - background-color: black; -} \ No newline at end of file diff --git a/server/vb/components/base_page.py b/server/vb/components/base_page.py index 6996860..d7074b3 100644 --- a/server/vb/components/base_page.py +++ b/server/vb/components/base_page.py @@ -2,11 +2,10 @@ from django.templatetags.static import static from markupsafe import Markup -from server.utils.components import style +from server.utils.components import css_vars, with_children from .faq import faq from .footer import footer -from .utils import with_children def _gtag_scripts() -> h.Node: @@ -41,7 +40,7 @@ def base_page( show_footer: bool = True, ) -> h.Element: """Render the generic structure for all pages on voterbowl.org.""" - return h.html(lang="en")[ + return h.html(lang="en", style=css_vars(bg_color=bg_color))[ h.head[ _gtag_scripts(), h.title[title], @@ -51,15 +50,13 @@ def base_page( h.meta(http_equiv="X-UA-Compatible", content="IE=edge"), h.meta(name="viewport", content="width=device-width, initial-scale=1.0"), h.meta(name="format-detection", content="telephone=no"), - h.link(rel="stylesheet", href=static("css/modern-normalize.min.css")), - h.link(rel="stylesheet", href=static("css/base.css")), + h.link(rel="stylesheet", href=static("css/voterbowl.css")), h.script(src=static("js/voterbowl.mjs"), type="module"), - style(__file__, "base_page.css", bg_color=bg_color), extra_head, ], h.body[ children, - h.div(".faq")[h.div(".container")[faq(school=None)]] if show_faq else None, + h.div("#faq")[h.div(".container")[faq(school=None)]] if show_faq else None, footer() if show_footer else None, ], ] diff --git a/server/vb/components/button.css b/server/vb/components/button.css deleted file mode 100644 index 11c0fea..0000000 --- a/server/vb/components/button.css +++ /dev/null @@ -1,20 +0,0 @@ -me { - cursor: pointer; - transition: opacity 0.2s ease-in-out; - text-transform: uppercase; - text-decoration: none; - font-weight: 600; - font-size: 18px; - line-height: 100%; - border: none; - text-align: center; - letter-spacing: 0.05em; - padding: 20px 24px; - background-color: var(--bg-color); - color: var(--color); -} - -me:hover { - opacity: 0.7; - transition: opacity 0.2s ease-in-out; -} \ No newline at end of file diff --git a/server/vb/components/button.py b/server/vb/components/button.py index 9d581e2..5f73f3c 100644 --- a/server/vb/components/button.py +++ b/server/vb/components/button.py @@ -1,13 +1,11 @@ import htpy as h -from server.utils.components import style - -from .utils import with_children +from server.utils.components import css_vars, with_children @with_children def button(children: h.Node, href: str, bg_color: str, color: str) -> h.Element: """Render a button with the given background and text color.""" - return h.a(href=href)[ - style(__file__, "button.css", bg_color=bg_color, color=color), children + return h.a(".button", href=href, style=css_vars(bg_color=bg_color, color=color))[ + children ] diff --git a/server/vb/components/check_page.css b/server/vb/components/check_page.css deleted file mode 100644 index 116e7fa..0000000 --- a/server/vb/components/check_page.css +++ /dev/null @@ -1,116 +0,0 @@ -me { - width: 100%; - display: flex; - flex-direction: column; - align-items: center; -} - -me main { - width: 100%; - text-align: center; - padding: 0.5rem 0; -} - -me main img { - height: 150px; - margin-bottom: -1.75rem; -} - -me main p { - font-weight: 378; - font-size: 20px; - line-height: 130%; -} - -me main h2 { - font-weight: 500; - font-size: 36px; - line-height: 120%; - text-transform: uppercase; -} - -me .faq { - width: 100%; - color: white; - padding: 2rem 0; -} - -me .button-holder { - display: flex; - justify-content: center; - margin: 1.5rem 0; -} - -me .form { - width: 100%; - background-color: white; - padding: 2rem 0; -} - -me .urgency { - flex-direction: column; - gap: 1rem; -} - -@media screen and (min-width: 768px) { - me main { - padding: 2rem 0; - } - - me main img { - height: 150px; - margin: 1.5rem 0; - } - - me .urgency { - flex-direction: row; - gap: 2rem; - } -} - -me main { - position: relative; - color: var(--main-color); - background-color: var(--main-bg-color); -} - -me main a { - color: var(--main-color); - transition: opacity 0.2s; -} - -me main a:hover { - opacity: 0.7; - transition: opacity 0.2s; -} - -me main .urgency { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; -} - -me main .fireworks { - pointer-events: none; - position: absolute; - top: 0; - left: 0; - bottom: 0; - right: 0; - overflow: hidden; -} - -me main .separate { - padding-left: 1rem; -} - -me main img { - display: block; -} - -@media screen and (min-width: 768px) { - me main .urgency { - flex-direction: row; - } -} diff --git a/server/vb/components/check_page.py b/server/vb/components/check_page.py index a9830c9..15131d3 100644 --- a/server/vb/components/check_page.py +++ b/server/vb/components/check_page.py @@ -3,13 +3,12 @@ from django.contrib.humanize.templatetags.humanize import naturaltime from django.urls import reverse -from server.utils.components import style +from server.utils.components import Fragment, css_vars, fragment from ..models import Contest, ContestEntry, School from .base_page import base_page from .countdown import countdown from .logo import school_logo -from .utils import Fragment, fragment def check_page(school: School, current_contest: Contest | None) -> h.Element: @@ -24,43 +23,40 @@ def check_page(school: School, current_contest: Contest | None) -> h.Element: show_faq=False, show_footer=False, )[ - h.check_page[ - h.div[ - style( - __file__, - "check_page.css", - main_color=school.logo.bg_text_color, - main_bg_color=school.logo.bg_color, - ), - h.main[ - h.div(".container")[ - h.div(".urgency")[ - school_logo(school), - countdown(current_contest) - if current_contest and not current_contest.is_no_prize - else h.div(".separate")[ - h.p[ - "Check your voter registration.", - h.br, - h.br, - "It only takes 30 seconds.", - ] - ], - ] - ], - h.div(".fireworks"), - ], - h.div(".form")[ - h.div(".container")[ - h.div( - ".voteamerica-embed", - data_subscriber="voterbowl", - data_tool="verify", - data_edition="college", - ) + h.check_page( + style=css_vars( + main_color=school.logo.bg_text_color, + main_bg_color=school.logo.bg_color, + ) + )[ + h.main[ + h.div(".container")[ + h.div(".urgency")[ + school_logo(school), + countdown(current_contest) + if current_contest and not current_contest.is_no_prize + else h.div(".separate")[ + h.p[ + "Check your voter registration.", + h.br, + h.br, + "It only takes 30 seconds.", + ] + ], ] ], - ] + h.div(".fireworks"), + ], + h.div(".form")[ + h.div(".container")[ + h.div( + ".voteamerica-embed", + data_subscriber="voterbowl", + data_tool="verify", + data_edition="college", + ) + ] + ], ] ] @@ -184,10 +180,5 @@ def finish_check_partial( data_is_winner="true" if contest_entry and contest_entry.is_winner else "false" - )[ - h.p[ - style(__file__, "finish_check_partial.css"), - _finish_check_description(school, contest_entry, most_recent_winner), - ] - ], + )[h.p[_finish_check_description(school, contest_entry, most_recent_winner)]], ] diff --git a/server/vb/components/countdown.css b/server/vb/components/countdown.css deleted file mode 100644 index e90eae8..0000000 --- a/server/vb/components/countdown.css +++ /dev/null @@ -1,40 +0,0 @@ -me { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding-bottom: 0.5rem; -} - -me p { - text-transform: uppercase; -} - -me .countdown { - display: flex; - justify-content: center; - align-items: center; - font-size: 24px; - font-weight: 500; - font-family: var(--font-mono); - gap: 4px; - height: 34px !important; -} - -me .countdown span { - display: flex; - justify-content: center; - align-items: center; - height: 100%; - width: 27px; -} - -me .countdown span.number { - color: var(--number-color); - background-color: var(--number-bg-color); -} - -me .countdown span.colon { - color: var(--colon-color); - background-color: transparent; -} \ No newline at end of file diff --git a/server/vb/components/countdown.py b/server/vb/components/countdown.py index 580f0dc..d5e9b9d 100644 --- a/server/vb/components/countdown.py +++ b/server/vb/components/countdown.py @@ -1,9 +1,70 @@ +import datetime +from dataclasses import dataclass +from math import floor + import htpy as h -from server.utils.components import style +from server.utils.components import css_vars from ..models import Contest -from .utils import remaining_time + +# ----------------------------------------------------------------------------- +# Generic Countdown Utils +# ----------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class RemainingTime: + """Render the remaining time until the given end time.""" + + h0: int + """The tens digit of the hours.""" + + h1: int + """The ones digit of the hours.""" + + m0: int + """The tens digit of the minutes.""" + + m1: int + """The ones digit of the minutes.""" + + s0: int + """The tens digit of the seconds.""" + + s1: int + """The ones digit of the seconds.""" + + @property + def ended(self) -> bool: + """Return whether the remaining time has ended.""" + return self.h0 == self.h1 == self.m0 == self.m1 == self.s0 == self.s1 == 0 + + +def remaining_time( + end_at: datetime.datetime, when: datetime.datetime | None = None +) -> RemainingTime: + """Render the remaining time until the given end time.""" + now = when or datetime.datetime.now(datetime.UTC) + delta = end_at - now + if delta.total_seconds() <= 0: + return RemainingTime(0, 0, 0, 0, 0, 0) + hours, remainder = divmod(delta.total_seconds(), 3600) + minutes, seconds = divmod(remainder, 60) + hours, minutes, seconds = map(floor, (hours, minutes, seconds)) + return RemainingTime( + h0=hours // 10, + h1=hours % 10, + m0=minutes // 10, + m1=minutes % 10, + s0=seconds // 10, + s1=seconds % 10, + ) + + +# ----------------------------------------------------------------------------- +# Big Countdown Area +# ----------------------------------------------------------------------------- def _describe_contest(contest: Contest) -> h.Node: @@ -53,25 +114,23 @@ def countdown(contest: Contest) -> h.Element: """Render a countdown timer for the given contest.""" logo = contest.school.logo remaining = remaining_time(contest.end_at) - return h.div[ - style( - __file__, - "countdown.css", + return h.big_countdown( + data_end_at=contest.end_at.isoformat(), + style=css_vars( number_color=logo.action_text_color, number_bg_color=logo.action_color, colon_color=logo.bg_text_color, ), + )[ _describe_contest(contest), - h.big_countdown(data_end_at=contest.end_at.isoformat())[ - h.div(".countdown")[ - h.span(".number", data_number="h0")[f"{remaining.h0}"], - h.span(".number", data_number="h1")[f"{remaining.h1}"], - h.span(".colon")[":"], - h.span(".number", data_number="m0")[f"{remaining.m0}"], - h.span(".number", data_number="m1")[f"{remaining.m1}"], - h.span(".colon")[":"], - h.span(".number", data_number="s0")[f"{remaining.s0}"], - h.span(".number", data_number="s1")[f"{remaining.s1}"], - ] + h.div(".countdown")[ + h.span(".number", data_number="h0")[f"{remaining.h0}"], + h.span(".number", data_number="h1")[f"{remaining.h1}"], + h.span(".colon")[":"], + h.span(".number", data_number="m0")[f"{remaining.m0}"], + h.span(".number", data_number="m1")[f"{remaining.m1}"], + h.span(".colon")[":"], + h.span(".number", data_number="s0")[f"{remaining.s0}"], + h.span(".number", data_number="s1")[f"{remaining.s1}"], ], ] diff --git a/server/vb/components/faq.css b/server/vb/components/faq.css deleted file mode 100644 index d3131a9..0000000 --- a/server/vb/components/faq.css +++ /dev/null @@ -1,37 +0,0 @@ -me { - display: flex; - flex-direction: column; -} - -me h2 { - font-size: 36px; - font-weight: 440; - line-height: 130%; - margin-bottom: 1rem; -} - -me h3 { - font-weight: 600; - font-size: 18px; - line-height: 28px; - margin-top: 1rem; -} - -me p { - font-weight: 378; - font-size: 18px; - line-height: 28px; - opacity: 0.7; -} - -me a { - color: white; - cursor: pointer; - text-decoration: underline; - transition: opacity 0.2s; -} - -me a:hover { - opacity: 0.7; - transition: opacity 0.2s; -} diff --git a/server/vb/components/faq.py b/server/vb/components/faq.py index 81f7f32..b350024 100644 --- a/server/vb/components/faq.py +++ b/server/vb/components/faq.py @@ -1,33 +1,10 @@ -import typing as t - import htpy as h -from server.utils.components import markdown_html, style +from server.utils.components import markdown_html from ..models import School -def qa(q: str, a: t.Iterable[h.Node]) -> h.Element: - """Render a question and answer.""" - return h.div(".qa")[ - h.h3[q], - (h.p[aa] for aa in a), - ] - - def faq(school: School | None) -> h.Element: """Render the frequently asked questions.""" - # TODO HTPY - # check_now: list[h.Node] = [ - # "Check now to avoid any last minute issues before the election." - # ] - # if school is not None: - # check_now = [ - # h.a(href=reverse("vb:check", args=[school.slug]))["Check now"], - # " to avoid any last minute issues before the election.", - # ] - - return h.div[ - style(__file__, "faq.css"), - markdown_html(__file__, "faq.md"), - ] + return h.div("#faq")[markdown_html(__file__, "faq.md")] diff --git a/server/vb/components/finish_check_partial.css b/server/vb/components/finish_check_partial.css deleted file mode 100644 index e957293..0000000 --- a/server/vb/components/finish_check_partial.css +++ /dev/null @@ -1,11 +0,0 @@ -me { - padding-top: 1rem; - margin-left: 0; -} - -@media screen and (min-width: 768px) { - me { - padding-top: 0; - margin-left: 1rem; - } -} diff --git a/server/vb/components/footer.css b/server/vb/components/footer.css deleted file mode 100644 index 0f4b027..0000000 --- a/server/vb/components/footer.css +++ /dev/null @@ -1,63 +0,0 @@ -me { - background-color: black; - color: #aaa; - padding-top: 4rem; - padding-bottom: 2rem; - padding-left: 0.5rem; - padding-right: 0.5rem; - width: 100%; -} - -@media screen and (min-width: 768px) { - me { - padding-left: 2em; - padding-right: 2rem; - } -} - -me div.center { - margin-bottom: 2em; - display: flex; - justify-content: center; - color: #fff; -} - -me div.center svg { - width: 120px !important; -} - -me div.outer { - display: flex; - flex-direction: column-reverse; - justify-content: space-between; - align-items: center; -} - -@media screen and (min-width: 768px) { - me div.outer { - flex-direction: row; - } -} - -me div.inner { - display: flex; - flex-direction: row; - gap: 1em; -} - -me a { - color: #aaa; - text-decoration: underline; -} - -me a:hover { - color: white; -} - -me .colophon { - text-align: center; - color: #888; - font-size: 0.8em; - padding-top: 1em; - padding-bottom: 3em; -} \ No newline at end of file diff --git a/server/vb/components/footer.py b/server/vb/components/footer.py index e7e65ce..d57223c 100644 --- a/server/vb/components/footer.py +++ b/server/vb/components/footer.py @@ -1,15 +1,12 @@ import htpy as h from django.urls import reverse -from server.utils.components import style - from .logo import VOTER_BOWL_LOGO def footer() -> h.Element: """Render the site-wide footer.""" return h.footer[ - style(__file__, "footer.css"), h.div(".center")[VOTER_BOWL_LOGO], h.div(".outer")[ h.p(".copyright")["© 2024 The Voter Bowl"], diff --git a/server/vb/components/home_page.css b/server/vb/components/home_page.css deleted file mode 100644 index 6cd6feb..0000000 --- a/server/vb/components/home_page.css +++ /dev/null @@ -1,75 +0,0 @@ -me { - width: 100%; - display: flex; - flex-direction: column; - align-items: center; - background-color: #cdff64; - color: black; -} - -me main { - width: 100%; - text-align: center; - padding: 2rem 0; -} - -me main svg { - width: 104px; - margin: 1.5rem 0; -} - -@media screen and (min-width: 768px) { - me main svg { - width: 112px; - } -} - -me main p { - font-weight: 378; - font-size: 20px; - line-height: 130%; -} - -me main h2 { - font-weight: 500; - font-size: 28px; - line-height: 140%; -} - -@media screen and (min-width: 768px) { - me main h2 { - font-size: 32px; - } -} - -me .button-holder { - display: flex; - justify-content: center; - margin: 1.5rem 0; -} - -me .ongoing { - display: flex; - flex-direction: column; - justify-content: center; - gap: 2rem; - margin: 2rem 0; -} - -me .upcoming { - display: flex; - flex-direction: column; - justify-content: center; - gap: 0.5rem; - margin: 0.5rem 0; -} - -me .coming-soon { - text-transform: uppercase; - font-weight: bold; - font-size: 20px; - line-height: 130%; - display: flex; - justify-content: center; - margin: 1.5rem 0; -} \ No newline at end of file diff --git a/server/vb/components/home_page.py b/server/vb/components/home_page.py index b13b95b..359ee37 100644 --- a/server/vb/components/home_page.py +++ b/server/vb/components/home_page.py @@ -2,8 +2,6 @@ import htpy as h -from server.utils.components import style - from ..models import Contest from .base_page import base_page from .logo import VOTER_BOWL_LOGO @@ -36,8 +34,7 @@ def home_page( upcoming_contests = list(upcoming_contests) return base_page[ - h.div[ - style(__file__, "home_page.css"), + h.div("#home-page")[ h.main[ h.div(".container")[ h.div(".center")[VOTER_BOWL_LOGO], diff --git a/server/vb/components/logo.py b/server/vb/components/logo.py index bdd8b4f..74e14e2 100644 --- a/server/vb/components/logo.py +++ b/server/vb/components/logo.py @@ -1,6 +1,6 @@ import htpy as h -from server.utils.components import style, svg +from server.utils.components import css_vars, svg from ..models import Logo, School @@ -18,16 +18,15 @@ def school_logo(school: School) -> h.Element: def logo_specimen(logo: Logo) -> h.Element: - """Render a school's logo as a specimen for our admin views.""" - return h.div[ - style( - __file__, - "logo_specimen.css", + """Render a school's logo as a specimen for Django admin views.""" + return h.logo_specimen( + style=css_vars( logo_bg_color=logo.bg_color, logo_bg_text_color=logo.bg_text_color, logo_action_color=logo.action_color, logo_action_text_color=logo.action_text_color, - ), + ) + )[ h.div(".bubble")[h.img(src=logo.url, alt="logo")], h.div(".bg")["text"], h.div(".action")["action"], diff --git a/server/vb/components/logo_specimen.css b/server/vb/components/logo_specimen.css deleted file mode 100644 index 577fac3..0000000 --- a/server/vb/components/logo_specimen.css +++ /dev/null @@ -1,49 +0,0 @@ -me { - display: flex; - gap: 0.5rem; -} - -me .bubble { - display: flex; - align-items: center; - overflow: hidden; - width: 48px; - height: 48px; - background-color: var(--logo-bg-color); -} - -me .bubble img { - display: block; - margin: 0 auto; - max-width: 80%; - max-height: 80%; -} - -me .bg { - display: flex; - font-weight: 600; - align-items: center; - justify-content: center; - padding-left: 0.5rem; - padding-right: 0.5rem; - background-color: var(--logo-bg-color); - color: var(--logo-bg-text-color); -} - -me .action { - cursor: pointer; - display: flex; - font-weight: 600; - align-items: center; - justify-content: center; - padding-left: 0.5rem; - padding-right: 0.5rem; - transition: opacity 0.2s; - background-color: var(--logo-action-color); - color: var(--logo-action-text-color); -} - -me .action:hover { - opacity: 0.7; - transition: opacity 0.2s ease-in-out; -} diff --git a/server/vb/components/ongoing_contest.css b/server/vb/components/ongoing_contest.css deleted file mode 100644 index 72f56cd..0000000 --- a/server/vb/components/ongoing_contest.css +++ /dev/null @@ -1,74 +0,0 @@ -me { - border: 3px solid black; - color: black; - font-weight: 400; - font-size: 18px; - line-height: 140%; - padding-left: 1em; - padding-right: 1em; - position: relative; -} - -me .content { - display: flex; - flex-direction: column; -} - -me .logo { - border-radius: 100%; - border: 2px solid black; - background-color: var(--logo-bg-color); - overflow: hidden; - width: 60px; - height: 60px; - margin: 1.5em auto 1em auto; -} - -me .logo img { - width: 100%; - height: 100%; - object-fit: contain; -} - -me .school { - margin: 0; - font-weight: 500; - font-size: 24px; - line-height: 100%; - display: flex; - justify-content: center; -} - -me .description { - margin-bottom: 0; -} - -me .button-holder { - width: 100%; -} - -me .button-holder a { - width: 100%; -} - -/* A centered box at the top of the card */ -me .box { - position: absolute; - top: -1em; - left: 50%; - transform: translateX(-50%); - border: 3px solid black; - background-color: #cdff64; - font-weight: 600; - line-height: 100%; - letter-spacing: 4%; - min-width: 70%; - padding: 0.25rem; - text-transform: uppercase; -} - -@media screen and (min-width: 768px) { - me .box { - min-width: 35%; - } -} diff --git a/server/vb/components/ongoing_contest.py b/server/vb/components/ongoing_contest.py index c577a74..d74fa9a 100644 --- a/server/vb/components/ongoing_contest.py +++ b/server/vb/components/ongoing_contest.py @@ -1,11 +1,23 @@ +import datetime + import htpy as h -from server.utils.components import style +from server.utils.components import css_vars from ..models import Contest from .button import button +from .countdown import remaining_time from .logo import school_logo -from .utils import small_countdown_str + + +def _format_countdown_str( + end_at: datetime.datetime, when: datetime.datetime | None = None +) -> str: + """Format the remaining time until the given end time.""" + rt = remaining_time(end_at, when) + if rt.ended: + return "Just ended!" + return f"Ends in {rt.h0}{rt.h1}:{rt.m0}{rt.m1}:{rt.s0}{rt.s1}" def _ongoing_description(contest: Contest) -> list[str]: @@ -52,10 +64,9 @@ def _ongoing_description(contest: Contest) -> list[str]: def ongoing_contest(contest: Contest) -> h.Element: """Render an ongoing contest.""" - return h.div[ - style( - __file__, "ongoing_contest.css", logo_bg_color=contest.school.logo.bg_color - ), + return h.div( + ".ongoing-contest", style=css_vars(logo_bg_color=contest.school.logo.bg_color) + )[ h.div(".content")[ school_logo(contest.school), h.p(".school")[contest.school.name], @@ -67,6 +78,6 @@ def ongoing_contest(contest: Contest) -> h.Element: ], ], h.small_countdown(data_end_at=contest.end_at.isoformat())[ - h.div(".box countdown")[small_countdown_str(contest.end_at)] + h.div(".box countdown")[_format_countdown_str(contest.end_at)] ], ] diff --git a/server/vb/components/rules_page.css b/server/vb/components/rules_page.css deleted file mode 100644 index b8c9949..0000000 --- a/server/vb/components/rules_page.css +++ /dev/null @@ -1,5 +0,0 @@ -me { - font-size: 1.25em; - line-height: 150%; - font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; -} diff --git a/server/vb/components/rules_page.py b/server/vb/components/rules_page.py index 394ceb0..24f89e2 100644 --- a/server/vb/components/rules_page.py +++ b/server/vb/components/rules_page.py @@ -1,6 +1,6 @@ import htpy as h -from server.utils.components import markdown_html, style +from server.utils.components import markdown_html from .base_page import base_page @@ -8,8 +8,5 @@ def rules_page() -> h.Element: """Render the rules page.""" return base_page(title="Voter Bowl Rules", bg_color="white", show_faq=False)[ - h.div(".container")[ - style(__file__, "rules_page.css"), - markdown_html(__file__, "rules.md"), - ] + h.div("#rules-page.container")[markdown_html(__file__, "rules.md"),] ] diff --git a/server/vb/components/school_page.css b/server/vb/components/school_page.css deleted file mode 100644 index 1c1fc57..0000000 --- a/server/vb/components/school_page.css +++ /dev/null @@ -1,68 +0,0 @@ -me { - width: 100%; - display: flex; - flex-direction: column; - align-items: center; -} - -me main { - width: 100%; - text-align: center; - padding-bottom: 2rem; - color: var(--color); - background-color: var(--bg-color); -} - -me main>div { - display: flex; - flex-direction: column; - justify-content: space-evenly; - min-height: calc(100dvh - env(safe-area-inset-bottom) - 2rem); -} - -@media screen and (min-width: 768px) { - me main>div { - padding: 2rem 0; - min-height: unset; - } -} - -me main img { - height: 150px; - margin: 1.5rem 0; -} - -me main p { - font-weight: 378; - font-size: 20px; - line-height: 130%; -} - -me main h2 { - font-weight: 500; - font-size: 36px; - line-height: 120%; - text-transform: uppercase; -} - -me .faq { - width: 100%; - color: white; - padding: 2rem 0; -} - -me .button-holder { - display: flex; - justify-content: center; - margin: 0; -} - -@media screen and (min-width: 768px) { - me .button-holder { - margin-top: 1.5rem; - } -} - -me .faq { - background-color: black; -} diff --git a/server/vb/components/school_page.py b/server/vb/components/school_page.py index 70b5d43..ab642c9 100644 --- a/server/vb/components/school_page.py +++ b/server/vb/components/school_page.py @@ -1,6 +1,6 @@ import htpy as h -from server.utils.components import style +from server.utils.components import css_vars from ..models import Contest, School from .base_page import base_page @@ -84,13 +84,13 @@ def school_page(school: School, current_contest: Contest | None) -> h.Element: return base_page( title=f"Voter Bowl x {school.name}", bg_color=school.logo.bg_color )[ - h.div[ - style( - __file__, - "school_page.css", + h.div( + "#school-page", + style=css_vars( bg_color=school.logo.bg_color, color=school.logo.bg_text_color, ), + )[ h.main[ h.div(".container")[ countdown(current_contest) diff --git a/server/vb/components/upcoming_contest.css b/server/vb/components/upcoming_contest.css deleted file mode 100644 index 79768e6..0000000 --- a/server/vb/components/upcoming_contest.css +++ /dev/null @@ -1,34 +0,0 @@ -me { - border: 3px solid black; - padding: 1rem; - color: black; - font-size: 18px; - font-weight: 440; - font-variation-settings: "wght" 440; - line-height: 1; -} - -me .content { - display: flex; - align-items: center; - gap: 1em; -} - -me .logo { - border-radius: 100%; - border: 2px solid black; - background-color: var(--logo-bg-color); - overflow: hidden; - width: 36px; - height: 36px; -} - -me .logo img { - width: 100%; - height: 100%; - object-fit: contain; -} - -me p { - margin: 0; -} \ No newline at end of file diff --git a/server/vb/components/upcoming_contest.py b/server/vb/components/upcoming_contest.py index 495582d..55a88fc 100644 --- a/server/vb/components/upcoming_contest.py +++ b/server/vb/components/upcoming_contest.py @@ -1,6 +1,6 @@ import htpy as h -from server.utils.components import style +from server.utils.components import css_vars from ..models import Contest from .logo import school_logo @@ -8,10 +8,9 @@ def upcoming_contest(contest: Contest) -> h.Element: """Render an upcoming contest.""" - return h.div[ - style( - __file__, "upcoming_contest.css", logo_bg_color=contest.school.logo.bg_color - ), + return h.div( + ".upcoming-contest", style=css_vars(logo_bg_color=contest.school.logo.bg_color) + )[ h.div(".content")[ school_logo(contest.school), h.p(".school")[contest.school.name], diff --git a/server/vb/components/utils.py b/server/vb/components/utils.py deleted file mode 100644 index 52e796d..0000000 --- a/server/vb/components/utils.py +++ /dev/null @@ -1,129 +0,0 @@ -import datetime -import typing as t -from dataclasses import dataclass, field, replace -from math import floor - -import htpy as h -from htpy import _iter_children as _h_iter_children -from markupsafe import Markup - -# FUTURE use PEP 695 syntax when mypy supports it -P = t.ParamSpec("P") -C = t.TypeVar("C") -R = t.TypeVar("R", h.Element, h.Node) - - -@dataclass(frozen=True) -class with_children(t.Generic[C, P, R]): - """Wrap a function to make it look more like an htpy.Element.""" - - _f: t.Callable[t.Concatenate[C, P], R] - _args: tuple[t.Any, ...] = field(default_factory=tuple) - _kwargs: t.Mapping[str, t.Any] = field(default_factory=dict) - - def __getitem__(self, children: C) -> R: - """Render the component with the given children.""" - return self._f(children, *self._args, **self._kwargs) # type: ignore - - def __call__(self, *args: P.args, **kwargs: P.kwargs) -> t.Self: - """Return a new instance of the class with the given arguments.""" - return replace(self, _args=args, _kwargs=kwargs) - - def __str__(self) -> str: - """Return the name of the function being wrapped.""" - # CONSIDER: alternatively, invoke the function here? If the function - # provides a default value for its arguments, it'll work; otherwise, - # it will blow up... which might be a good thing? - return f"with_children[{self._f.__name__}]" - - -class Fragment: - """ - A fragment of HTML with no explicit parent element. - - CONSIDER: this feels like it should perhaps be the base class from - which htpy.Element inherits? It's a container for children, without - an Element's tag/attributes. And htpy.Node should probably include - this as well. - """ - - __slots__ = ("_children",) - - def __init__(self, children: h.Node) -> None: - """Initialize the fragment with the given children.""" - self._children = children - - def __getitem__(self, children: h.Node) -> t.Self: - """Return a new fragment with the given children.""" - return self.__class__(children) - - def __str__(self) -> Markup: - """Return the fragment as a string.""" - return Markup("".join(str(x) for x in self)) - - def __iter__(self): - """Iterate over the children of the fragment.""" - # XXX I'm using a private method here, which is not ideal. - yield from _h_iter_children(self._children) - - -fragment = Fragment(None) - - -@dataclass(frozen=True) -class RemainingTime: - """Render the remaining time until the given end time.""" - - h0: int - """The tens digit of the hours.""" - - h1: int - """The ones digit of the hours.""" - - m0: int - """The tens digit of the minutes.""" - - m1: int - """The ones digit of the minutes.""" - - s0: int - """The tens digit of the seconds.""" - - s1: int - """The ones digit of the seconds.""" - - @property - def ended(self) -> bool: - """Return whether the remaining time has ended.""" - return self.h0 == self.h1 == self.m0 == self.m1 == self.s0 == self.s1 == 0 - - -def remaining_time( - end_at: datetime.datetime, when: datetime.datetime | None = None -) -> RemainingTime: - """Render the remaining time until the given end time.""" - now = when or datetime.datetime.now(datetime.UTC) - delta = end_at - now - if delta.total_seconds() <= 0: - return RemainingTime(0, 0, 0, 0, 0, 0) - hours, remainder = divmod(delta.total_seconds(), 3600) - minutes, seconds = divmod(remainder, 60) - hours, minutes, seconds = map(floor, (hours, minutes, seconds)) - return RemainingTime( - h0=hours // 10, - h1=hours % 10, - m0=minutes // 10, - m1=minutes % 10, - s0=seconds // 10, - s1=seconds % 10, - ) - - -def small_countdown_str( - end_at: datetime.datetime, when: datetime.datetime | None = None -) -> str: - """Render the remaining time until the given end time.""" - rt = remaining_time(end_at, when) - if rt.ended: - return "Just ended!" - return f"Ends in {rt.h0}{rt.h1}:{rt.m0}{rt.m1}:{rt.s0}{rt.s1}" diff --git a/server/vb/components/validate_email_page.css b/server/vb/components/validate_email_page.css deleted file mode 100644 index d0ef1de..0000000 --- a/server/vb/components/validate_email_page.css +++ /dev/null @@ -1,110 +0,0 @@ -me { - width: 100%; - display: flex; - flex-direction: column; - align-items: center; -} - -me a { - color: var(--main-color); - text-decoration: underline; - transition: opacity 0.2s; -} - -me a:hover { - opacity: 0.7; - transition: opacity 0.2s; -} - -me main { - width: 100%; - text-align: center; - padding: 2rem 0; -} - -me main img { - height: 150px; - margin: 1.5rem 0; -} - -me main p { - font-weight: 378; - font-size: 20px; - line-height: 130%; -} - -me main h2 { - font-weight: 500; - font-size: 36px; - line-height: 120%; - text-transform: uppercase; -} - -me .faq { - width: 100%; - color: white; - background-color: black; - padding: 2rem 0; -} - -me .button-holder { - display: flex; - justify-content: center; - margin: 1.5rem 0; -} - -me main { - color: var(--main-color); - background-color: var(--main-bg-color); -} - -me main h2 { - display: flex; - justify-content: center; - align-items: center; -} - -me main .hidden { - display: none; -} - -me main .code { - font-size: 0.75em; -} - -me main .clipboard, -me main .copied { - margin-left: 0.2em; - margin-top: 0.05em; - width: 0.75em; -} - -me main .clipboard { - opacity: 0.5; - cursor: pointer; - transition: opacity 0.2s; -} - -me main .clipboard:hover { - opacity: 1; - transition: opacity 0.2s; -} - -me main .copied { - opacity: 0.5; -} - -@media screen and (min-width: 768px) { - - me main .clipboard, - me main .copied { - margin-left: 0.2em; - margin-top: 0.2em; - width: 1em; - } - - - me main .code { - font-size: 1em; - } -} diff --git a/server/vb/components/validate_email_page.py b/server/vb/components/validate_email_page.py index 83455bb..e9515c4 100644 --- a/server/vb/components/validate_email_page.py +++ b/server/vb/components/validate_email_page.py @@ -2,7 +2,7 @@ from django.conf import settings from django.urls import reverse -from server.utils.components import style, svg +from server.utils.components import css_vars, svg from ..models import ContestEntry, School from .base_page import base_page @@ -58,13 +58,12 @@ def validate_email_page( return base_page( title=f"Voter Bowl x {school.short_name}", bg_color=school.logo.bg_color )[ - h.div[ - style( - __file__, - "validate_email_page.css", - main_color=school.logo.bg_text_color, - main_bg_color=school.logo.bg_color, + h.div( + "#validate-email", + style=css_vars( + main_color=school.logo.bg_text_color, main_bg_color=school.logo.bg_color ), + )[ h.main[ h.gift_code[ h.div(".container")[