From 9c19c0a9fd760c6117fafd3674932dbfd0a0131a Mon Sep 17 00:00:00 2001 From: florimondmanca Date: Wed, 6 Mar 2024 00:24:53 +0100 Subject: [PATCH 1/6] Add diypedals.fm sub-site --- Makefile | 1 + server/diypedals/.gitignore | 1 + server/diypedals/__init__.py | 0 server/diypedals/di.py | 48 +++++++ server/diypedals/domain/__init__.py | 0 server/diypedals/domain/entities.py | 35 +++++ server/diypedals/domain/repositories.py | 12 ++ server/diypedals/infrastructure/__init__.py | 0 server/diypedals/infrastructure/cache.py | 31 +++++ server/diypedals/infrastructure/content.py | 13 ++ server/diypedals/infrastructure/database.py | 49 +++++++ server/diypedals/infrastructure/di.py | 26 ++++ .../diypedals/infrastructure/repositories.py | 17 +++ server/diypedals/infrastructure/urls.py | 27 ++++ server/diypedals/infrastructure/webdav.py | 119 ++++++++++++++++ server/diypedals/settings.py | 25 ++++ server/diypedals/web/__init__.py | 0 server/diypedals/web/app.py | 38 ++++++ server/diypedals/web/reload.py | 17 +++ server/diypedals/web/routes.py | 39 ++++++ server/diypedals/web/static/.gitignore | 2 + .../diypedals/web/static/apple-touch-icon.png | Bin 0 -> 4182 bytes server/diypedals/web/static/css/.gitkeep | 0 server/diypedals/web/static/favicon.ico | Bin 0 -> 7406 bytes .../web/static/img/build_reports/.gitkeep | 0 server/diypedals/web/static/manifest.json | 21 +++ server/diypedals/web/static/robots.txt | 2 + server/diypedals/web/statics.py | 19 +++ server/diypedals/web/styles/defaults.css | 127 ++++++++++++++++++ server/diypedals/web/styles/layouts/box.css | 9 ++ .../diypedals/web/styles/layouts/cluster.css | 7 + .../web/styles/layouts/container.css | 14 ++ server/diypedals/web/styles/layouts/frame.css | 15 +++ server/diypedals/web/styles/layouts/grid.css | 10 ++ server/diypedals/web/styles/layouts/index.css | 6 + server/diypedals/web/styles/layouts/stack.css | 7 + server/diypedals/web/styles/styles.css | 3 + server/diypedals/web/styles/variables.css | 37 +++++ server/diypedals/web/templates/base.jinja | 68 ++++++++++ .../views/build_reports/_detail_photos.jinja | 7 + .../views/build_reports/detail.jinja | 36 +++++ .../templates/views/build_reports/list.jinja | 63 +++++++++ .../diypedals/web/templates/views/index.jinja | 5 + server/diypedals/web/templating.py | 33 +++++ server/diypedals/web/views.py | 54 ++++++++ server/main.py | 2 + server/web/app.py | 8 +- server/web/routes.py | 6 +- 48 files changed, 1055 insertions(+), 4 deletions(-) create mode 100644 server/diypedals/.gitignore create mode 100644 server/diypedals/__init__.py create mode 100644 server/diypedals/di.py create mode 100644 server/diypedals/domain/__init__.py create mode 100644 server/diypedals/domain/entities.py create mode 100644 server/diypedals/domain/repositories.py create mode 100644 server/diypedals/infrastructure/__init__.py create mode 100644 server/diypedals/infrastructure/cache.py create mode 100644 server/diypedals/infrastructure/content.py create mode 100644 server/diypedals/infrastructure/database.py create mode 100644 server/diypedals/infrastructure/di.py create mode 100644 server/diypedals/infrastructure/repositories.py create mode 100644 server/diypedals/infrastructure/urls.py create mode 100644 server/diypedals/infrastructure/webdav.py create mode 100644 server/diypedals/settings.py create mode 100644 server/diypedals/web/__init__.py create mode 100644 server/diypedals/web/app.py create mode 100644 server/diypedals/web/reload.py create mode 100644 server/diypedals/web/routes.py create mode 100644 server/diypedals/web/static/.gitignore create mode 100644 server/diypedals/web/static/apple-touch-icon.png create mode 100644 server/diypedals/web/static/css/.gitkeep create mode 100644 server/diypedals/web/static/favicon.ico create mode 100644 server/diypedals/web/static/img/build_reports/.gitkeep create mode 100644 server/diypedals/web/static/manifest.json create mode 100644 server/diypedals/web/static/robots.txt create mode 100644 server/diypedals/web/statics.py create mode 100644 server/diypedals/web/styles/defaults.css create mode 100644 server/diypedals/web/styles/layouts/box.css create mode 100644 server/diypedals/web/styles/layouts/cluster.css create mode 100644 server/diypedals/web/styles/layouts/container.css create mode 100644 server/diypedals/web/styles/layouts/frame.css create mode 100644 server/diypedals/web/styles/layouts/grid.css create mode 100644 server/diypedals/web/styles/layouts/index.css create mode 100644 server/diypedals/web/styles/layouts/stack.css create mode 100644 server/diypedals/web/styles/styles.css create mode 100644 server/diypedals/web/styles/variables.css create mode 100644 server/diypedals/web/templates/base.jinja create mode 100644 server/diypedals/web/templates/views/build_reports/_detail_photos.jinja create mode 100644 server/diypedals/web/templates/views/build_reports/detail.jinja create mode 100644 server/diypedals/web/templates/views/build_reports/list.jinja create mode 100644 server/diypedals/web/templates/views/index.jinja create mode 100644 server/diypedals/web/templating.py create mode 100644 server/diypedals/web/views.py diff --git a/Makefile b/Makefile index def07bd8..954e4500 100644 --- a/Makefile +++ b/Makefile @@ -39,6 +39,7 @@ build: # Build assets pybuild: ${bin}python -m tools.pybuild ./server/web/styles/styles.css --outdir ./server/web/static/css ${ARGS} + ${bin}python -m tools.pybuild ./server/diypedals/web/styles/styles.css --outdir ./server/diypedals/web/static/css ${ARGS} ## ## ---------------- diff --git a/server/diypedals/.gitignore b/server/diypedals/.gitignore new file mode 100644 index 00000000..e934adfd --- /dev/null +++ b/server/diypedals/.gitignore @@ -0,0 +1 @@ +cache/ diff --git a/server/diypedals/__init__.py b/server/diypedals/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/diypedals/di.py b/server/diypedals/di.py new file mode 100644 index 00000000..a57baa19 --- /dev/null +++ b/server/diypedals/di.py @@ -0,0 +1,48 @@ +from typing import TypeVar + +import httpx + +from . import settings +from .domain.repositories import BuildReportRepository +from .infrastructure.cache import DiskCache +from .infrastructure.di import Container +from .infrastructure.webdav import BuildReportClient + +T = TypeVar("T") + + +def _configure(container: Container) -> None: + from .infrastructure.database import InMemoryDatabase + from .infrastructure.repositories import InMemoryBuildReportRepository + from .web.reload import HotReload + from .web.templating import Templates + + db = InMemoryDatabase() + container.register(InMemoryDatabase, instance=db) + + hotreload = HotReload() + container.register(HotReload, instance=hotreload) + + container.register(Templates, instance=Templates(hotreload)) + + container.register( + BuildReportRepository, instance=InMemoryBuildReportRepository(db) + ) + container.register( + BuildReportClient, + instance=BuildReportClient( + username=settings.WEBDAV_USERNAME, + password=str(settings.WEBDAV_PASSWORD), + cache=DiskCache(directory=settings.BUILD_REPORTS_CACHE_DIR), + ), + ) + + +def create_container() -> Container: + return Container(_configure) + + +_container = create_container() + +bootstrap = _container.bootstrap +resolve = _container.resolve diff --git a/server/diypedals/domain/__init__.py b/server/diypedals/domain/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/diypedals/domain/entities.py b/server/diypedals/domain/entities.py new file mode 100644 index 00000000..b850e920 --- /dev/null +++ b/server/diypedals/domain/entities.py @@ -0,0 +1,35 @@ +import datetime as dt +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class Kit: + name: str + seller: str + url: str + + +@dataclass(frozen=True) +class Pcb: + name: str + seller: str + url: str + + +@dataclass(frozen=True) +class Photo: + src: str + alt: str + + +@dataclass(frozen=True) +class BuildReport: + title: str + slug: str + description: str + categories: list + build_date: dt.date + status: str + photos: list[Photo] = field(default_factory=list) + kit: Kit | None = None + pcb: Pcb | None = None diff --git a/server/diypedals/domain/repositories.py b/server/diypedals/domain/repositories.py new file mode 100644 index 00000000..c56cb63b --- /dev/null +++ b/server/diypedals/domain/repositories.py @@ -0,0 +1,12 @@ +from .entities import BuildReport + + +class BuildReportRepository: + async def save(self, build_report: BuildReport) -> None: + raise NotImplementedError # pragma: no cover + + async def find_all(self) -> list[BuildReport]: + raise NotImplementedError # pragma: no cover + + async def find_one(self, *, slug: str) -> BuildReport: + raise NotImplementedError # pragma: no cover diff --git a/server/diypedals/infrastructure/__init__.py b/server/diypedals/infrastructure/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/diypedals/infrastructure/cache.py b/server/diypedals/infrastructure/cache.py new file mode 100644 index 00000000..6c0afd6d --- /dev/null +++ b/server/diypedals/infrastructure/cache.py @@ -0,0 +1,31 @@ +import json +from pathlib import Path +from typing import Callable + +from .. import settings + + +class DiskCache: + def __init__(self, directory: Path) -> None: + self._dir = directory + self._dir.mkdir(exist_ok=True) + self._file = self._dir / "cache.json" + if not self._file.exists(): + self._file.write_text("{}") + + def _get_cache(self) -> dict: + return json.loads(self._file.read_text()) + + def _write_cache(self, cache: dict) -> None: + self._file.write_text(json.dumps(cache)) + + async def get(self, key: str, fetch_func: Callable): + cache = self._get_cache() + + try: + return cache[key] + except KeyError: + value = await fetch_func() + cache[key] = value + self._write_cache(cache) + return value diff --git a/server/diypedals/infrastructure/content.py b/server/diypedals/infrastructure/content.py new file mode 100644 index 00000000..df686da0 --- /dev/null +++ b/server/diypedals/infrastructure/content.py @@ -0,0 +1,13 @@ +from .. import settings +from ..di import resolve +from ..domain.repositories import BuildReportRepository +from .cache import DiskCache +from .webdav import BuildReportClient + + +async def load_build_reports() -> None: + repository = resolve(BuildReportRepository) + client = resolve(BuildReportClient) + + async for build_report in client.fetch_all(): + await repository.save(build_report) diff --git a/server/diypedals/infrastructure/database.py b/server/diypedals/infrastructure/database.py new file mode 100644 index 00000000..a767d067 --- /dev/null +++ b/server/diypedals/infrastructure/database.py @@ -0,0 +1,49 @@ +from contextlib import contextmanager +from typing import Iterator + +from .. import settings +from ..domain.entities import BuildReport +from .content import load_build_reports + + +class _Data: + def __init__(self) -> None: + self.build_reports: dict[str, BuildReport] = {} + + +class InMemoryDatabase: + def __init__(self) -> None: + self._data = _Data() + + async def connect(self) -> None: + await self._load() + + async def reload(self) -> None: + await self._load() # pragma: no cover + + @contextmanager + def isolated(self) -> Iterator[None]: + previous_data = self._data + self._data = _Data() + try: + yield + finally: + self._data = previous_data + + async def _load(self) -> None: + self._data = _Data() + + await load_build_reports() + + def find_all_build_reports(self) -> list[BuildReport]: + return sorted( + self._data.build_reports.values(), + key=lambda build_report: build_report.build_date, + reverse=True, + ) + + def find_one_build_report(self, slug: str) -> BuildReport | None: + return self._data.build_reports.get(slug) + + def insert_build_report(self, build_report: BuildReport) -> None: + self._data.build_reports[build_report.slug] = build_report diff --git a/server/diypedals/infrastructure/di.py b/server/diypedals/infrastructure/di.py new file mode 100644 index 00000000..354c7c8d --- /dev/null +++ b/server/diypedals/infrastructure/di.py @@ -0,0 +1,26 @@ +from typing import Callable, Type, TypeVar + +import punq + +T = TypeVar("T") + + +class Container: + def __init__(self, configure: Callable[["Container"], None]) -> None: + self._impl = punq.Container() + self._configure = configure + self._is_configured = False + + def register(self, type_: Type[T], *, instance: T) -> None: + self._impl.register(type_, instance=instance) + + def bootstrap(self) -> None: + if self._is_configured: + return # pragma: no cover + + self._configure(self) + self._is_configured = True + + def resolve(self, type_: Type[T]) -> T: + assert self._is_configured, "DI not configured: call bootstrap()" + return self._impl.resolve(type_) diff --git a/server/diypedals/infrastructure/repositories.py b/server/diypedals/infrastructure/repositories.py new file mode 100644 index 00000000..9ca1083d --- /dev/null +++ b/server/diypedals/infrastructure/repositories.py @@ -0,0 +1,17 @@ +from ..domain.entities import BuildReport +from ..domain.repositories import BuildReportRepository +from .database import InMemoryDatabase + + +class InMemoryBuildReportRepository(BuildReportRepository): + def __init__(self, db: InMemoryDatabase) -> None: + self._db = db + + async def save(self, build_report: BuildReport) -> None: + self._db.insert_build_report(build_report) + + async def find_all(self) -> list[BuildReport]: + return self._db.find_all_build_reports() + + async def find_one(self, *, slug: str) -> BuildReport: + return self._db.find_one_build_report(slug) diff --git a/server/diypedals/infrastructure/urls.py b/server/diypedals/infrastructure/urls.py new file mode 100644 index 00000000..f36a986b --- /dev/null +++ b/server/diypedals/infrastructure/urls.py @@ -0,0 +1,27 @@ +from typing import Any + +import httpx + +from .. import settings +from ..domain.entities import BuildReport + + +def to_production_url(url: str) -> str: + urlobj = httpx.URL(url) + + scheme, host, port = ( + ("http", f"localhost", settings.PORT) + if settings.DEBUG + else ("http" if settings.TESTING else "https", f"florimond.dev", None) + ) + + path = "/diypedals" + urlobj.path + + return str(urlobj.copy_with(scheme=scheme, host=host, port=port, path=path)) + + +def get_absolute_path(obj: Any) -> str: + if isinstance(obj, BuildReport): + return f"/build-reports/{obj.slug}" + + raise ValueError(f"No absolute URL defined for {obj!r}") diff --git a/server/diypedals/infrastructure/webdav.py b/server/diypedals/infrastructure/webdav.py new file mode 100644 index 00000000..fe6e38b1 --- /dev/null +++ b/server/diypedals/infrastructure/webdav.py @@ -0,0 +1,119 @@ +import datetime as dt +import http +import itertools +import xml.etree.ElementTree as ET +from base64 import b64encode +from typing import AsyncIterator + +import httpx + +from .. import settings +from ..domain.entities import BuildReport, Kit, Pcb, Photo +from .cache import DiskCache + + +class BuildReportClient: + def __init__(self, username: str, password: str, cache: DiskCache) -> None: + self._cache = cache + self._http = httpx.AsyncClient( + auth=httpx.BasicAuth(username, password), + timeout=httpx.Timeout(5, connect=15), + ) + + async def fetch_all(self) -> AsyncIterator[BuildReport]: + # https://docs.nextcloud.com/server/latest/developer_manual/client_apis/WebDAV/basic.html + + webdav_url = httpx.URL(settings.BUILD_REPORTS_WEBDAV_URL) + + ls_content = """ + + + + + + + + """ + r = await self._http.request("PROPFIND", webdav_url, content=ls_content) + + xml = ET.fromstring(r.text) + ns = {"d": "DAV:"} + + build_reports = [] + + for item in itertools.islice(xml, 1, None): # First is the folder itself + if item.find(".//d:resourcetype/d:collection", ns) is None: + continue + + href = item.find(".//d:href", ns) + photos_url = webdav_url.copy_with(path=href.text + "entry.json") + r = await self._http.request("GET", photos_url) + r.raise_for_status() + data = r.json() + + photos_url = webdav_url.copy_with(path=href.text + "photos") + + ls_photos_content = """ + + + + + + + + + """ + + photos_response = await self._http.request( + "PROPFIND", photos_url, content=ls_photos_content + ) + + photos = [] + + if photos_response.status_code == http.HTTPStatus.MULTI_STATUS.value: + photos_xml = ET.fromstring(photos_response.text) + + for photo_item in itertools.islice(photos_xml, 1, None): + if ( + photo_item.find(".//d:resourcetype/d:collection", ns) + is not None + ): + continue + + if not photo_item.find(".//d:getcontenttype", ns).text.startswith( + "image/" + ): + continue + + async def _fetch_photo(): + # Browser won't be able to download the image as it is behind authentication. + # Need to download the image and serve it as base64. + # See: https://stackoverflow.com/a/62305417 + photo_url = webdav_url.copy_with( + path=photo_item.find(".//d:href", ns).text + ) + photo_response = await self._http.request("GET", photo_url) + photo_content = photo_response.text + + photo_src = "data:%s;base64,%s" % ( + photo_response.headers["content-type"], + b64encode(photo_response.content).decode(), + ) + + return {"src": photo_src, "alt": "Photo"} + + photo_etag = photo_item.find(".//d:getetag", ns).text + photo = Photo(**(await self._cache.get(photo_etag, _fetch_photo))) + photos.append(photo) + + yield BuildReport( + title=data["title"], + slug=data["slug"], + description=data["description"], + categories=data["categories"], + build_date=dt.date.fromisoformat(data["build_date"]), + status=data["status"], + photos=photos, + kit=Kit(**data.get("kit")) if data.get("kit") else None, + pcb=Pcb(**data.get("pcb")) if data.get("pcb") else None, + ) diff --git a/server/diypedals/settings.py b/server/diypedals/settings.py new file mode 100644 index 00000000..b2d101c0 --- /dev/null +++ b/server/diypedals/settings.py @@ -0,0 +1,25 @@ +import pathlib + +from starlette.config import Config +from starlette.datastructures import Secret + +config = Config(".env") + +HERE = pathlib.Path(__file__).parent + +HOST: str = config("WWW_HOST", cast=str, default="localhost") +PORT: int = config("WWW_PORT", cast=int, default=8000) + +DEBUG: bool = config("WWW_DEBUG", cast=bool, default=False) +TESTING: bool = config("WWW_TESTING", cast=bool, default=False) + +STATIC_ROOT = "/static" +STATIC_DIR = HERE / "web" / "static" +TEMPLATES_DIR = HERE / "web" / "templates" + +WEBDAV_USERNAME = config("DIYPEDALS_WEBDAV_USERNAME", default=None) +WEBDAV_PASSWORD: Secret = config("DIYPEDALS_WEBDAV_PASSWORD", cast=Secret, default=None) + +BUILD_REPORTS_WEBDAV_URL = config("DIYPEDALS_BUILD_REPORTS_WEBDAV_URL", default=None) +BUILD_REPORTS_IMG_CDIR = STATIC_DIR / "diypedals" / "img" / "build_reports" +BUILD_REPORTS_CACHE_DIR = HERE / "cache" diff --git a/server/diypedals/web/__init__.py b/server/diypedals/web/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/diypedals/web/app.py b/server/diypedals/web/app.py new file mode 100644 index 00000000..cf2a4747 --- /dev/null +++ b/server/diypedals/web/app.py @@ -0,0 +1,38 @@ +from contextlib import asynccontextmanager + +from starlette.routing import BaseRoute +from starlette.types import ASGIApp + +from .. import settings +from ..di import resolve +from ..infrastructure.database import InMemoryDatabase +from .reload import HotReload +from .routes import get_routes + + +class DiyPedals: + def __init__(self) -> None: + self._routes = get_routes() + + @asynccontextmanager + async def lifespan(self): + db = resolve(InMemoryDatabase) + hotreload = resolve(HotReload) + + await db.connect() + + if settings.DEBUG: # pragma: no cover + await hotreload.startup() + + yield + + if settings.DEBUG: # pragma: no cover + await hotreload.shutdown() + + @property + def routes(self) -> list[BaseRoute]: + return self._routes + + +def create_app() -> DiyPedals: + return DiyPedals() diff --git a/server/diypedals/web/reload.py b/server/diypedals/web/reload.py new file mode 100644 index 00000000..e2fd5086 --- /dev/null +++ b/server/diypedals/web/reload.py @@ -0,0 +1,17 @@ +from pathlib import Path + +import arel + +from .. import settings + +HERE = Path(__file__).parent + + +class HotReload(arel.HotReload): + def __init__(self) -> None: + super().__init__( + paths=[ + arel.Path(settings.TEMPLATES_DIR), + arel.Path(settings.STATIC_DIR), + ] + ) diff --git a/server/diypedals/web/routes.py b/server/diypedals/web/routes.py new file mode 100644 index 00000000..65ddc7c9 --- /dev/null +++ b/server/diypedals/web/routes.py @@ -0,0 +1,39 @@ +from starlette.routing import BaseRoute, Host, Mount, Route, Router, WebSocketRoute + +from .. import settings +from ..di import resolve +from . import views +from .reload import HotReload +from .statics import CachedStaticFiles + + +def get_routes() -> list[BaseRoute]: + static = CachedStaticFiles( + directory=str(settings.STATIC_DIR), + max_age=7 * 86400, # 7 days + ) + + hotreload = resolve(HotReload) + + routes = [ + Mount(settings.STATIC_ROOT, static, name="static"), + # These files need to be exposed at the root, not '/static/'. + Route("/favicon.ico", static, name="favicon"), + Route("/robots.txt", static, name="robots"), + Route("/", views.Home, name="home"), + Route( + "/build-reports", + views.BuildReportList, + name="build_reports", + ), + Route( + "/build-reports/{slug:str}", + views.BuildReportDetail, + name="build_reports:detail", + ), + ] + + if settings.DEBUG: # pragma: no cover + routes += [WebSocketRoute("/hot-reload", hotreload, name="hot-reload")] + + return routes diff --git a/server/diypedals/web/static/.gitignore b/server/diypedals/web/static/.gitignore new file mode 100644 index 00000000..276eea90 --- /dev/null +++ b/server/diypedals/web/static/.gitignore @@ -0,0 +1,2 @@ +css/styles.css +img/build_reports/*.jpg diff --git a/server/diypedals/web/static/apple-touch-icon.png b/server/diypedals/web/static/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..0afc7508e5a963c06a9007018f88c7566286a8e9 GIT binary patch literal 4182 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 j(tWTq(?w None: + super().__init__(*args, **kwargs) + self._max_age = max_age + + def file_response(self, *args: Any, **kwargs: Any) -> Response: + response = super().file_response(*args, **kwargs) + + # Cache all static assets. + response.headers.append("Cache-Control", f"public, max-age={self._max_age}") + response.headers.append("Vary", "Accept-Encoding, User-Agent, Cookie, Referer") + + return response diff --git a/server/diypedals/web/styles/defaults.css b/server/diypedals/web/styles/defaults.css new file mode 100644 index 00000000..c62b4504 --- /dev/null +++ b/server/diypedals/web/styles/defaults.css @@ -0,0 +1,127 @@ +html { + font-size: var(--font-base); +} + +body { + font-family: var(--font-family); + font-size: var(--font-size-default); + background-color: var(--background-default); + color: var(--text-on-background-default); + line-height: 1.5; + padding: 0; + margin: 0; +} + +h1 { + font-size: var(--font-size-h1); + font-weight: bold; + margin-top: 0; + margin-bottom: var(--w); + line-height: 1.25; +} + +h2 { + font-size: var(--font-size-h2); + font-weight: normal; + margin-top: 0; + margin-bottom: var(--w); +} + +h3, .f-h3 { + font-size: var(--font-size-h3); + font-weight: bold; + margin-top: 0; + margin-bottom: var(--w); +} + +p { + margin-top: 0; + margin-bottom: var(--w); +} + +ul { + list-style-type: disc; +} + +.f-raw-list { + list-style-type: none; + padding: 0; + margin: 0; +} + +a { + color: inherit; + text-decoration: none; +} + +a:visited { + color: inherit; +} + +a:hover { + color: var(--primary); +} + +hr { + background-color: var(--muted); +} + +blockquote { + margin-left: var(--w); + padding-block: var(--w); + padding-left: calc(3 * var(--w)); + border-left: 1px solid var(--muted); + position: relative; +} + +blockquote:before { + /* White rectangle for quotation mark to sit on */ + content: ""; + background-color: var(--background-default); + position: absolute; + top: 50%; + left: -4px; + height: 2rem; + width: 5px; + margin-top: -1rem; +} + +blockquote:after { + /* Quotation mark */ + content: "\201C"; + position: absolute; + top: 50%; + left: -0.5em; + line-height: 1em; + text-align: center; + text-indent: -2px; + width: 1em; + margin-top: -0.4em; + color: var(--muted); + font-size: var(--font-size-blockquote-mark); +} + +blockquote > cite { + font-style: italic; +} + +/* Tables */ +/* Inspired by: https://github.com/vuejs/vuepress/blob/1a930dc22e4298b648dad37fa07e2442cdcb3c10/packages/%40vuepress/theme-default/styles/index.styl#L163-L177 */ + +table { + border-collapse: collapse; + display: block; + margin: 1rem 0; + overflow-x: auto; +} +tr { + border-top: 1px solid #dfe2e5; +} +tr:nth-child(2n) { + background-color: #f6f8fa; +} +th, +td { + border: 1px solid #dfe2e5; + padding: 0.6em 1em; +} diff --git a/server/diypedals/web/styles/layouts/box.css b/server/diypedals/web/styles/layouts/box.css new file mode 100644 index 00000000..b2ffd275 --- /dev/null +++ b/server/diypedals/web/styles/layouts/box.css @@ -0,0 +1,9 @@ +.f-box { + padding: var(--box-padding, 0); +} + +@media screen and (min-width: 900px) { + .f-box { + padding: var(--box-padding-desktop, var(--box-padding, 0)); + } +} diff --git a/server/diypedals/web/styles/layouts/cluster.css b/server/diypedals/web/styles/layouts/cluster.css new file mode 100644 index 00000000..e105988a --- /dev/null +++ b/server/diypedals/web/styles/layouts/cluster.css @@ -0,0 +1,7 @@ +.f-cluster { + display: flex; + flex-wrap: var(--cluster-wrap, wrap); + justify-content: var(--cluster-justify, flex-start); + align-content: var(--cluster-align, flex-start); + gap: var(--cluster-gap, 0); +} diff --git a/server/diypedals/web/styles/layouts/container.css b/server/diypedals/web/styles/layouts/container.css new file mode 100644 index 00000000..e77f76f7 --- /dev/null +++ b/server/diypedals/web/styles/layouts/container.css @@ -0,0 +1,14 @@ +.f-container { + max-width: var(--container-width, 65ch); + margin-inline: auto; + padding-inline: var(--container-padding, calc(2 * var(--w))); +} + +@media screen and (min-width: 900px) { + .f-container { + padding-inline: var( + --container-padding-desktop, + var(--container-padding, calc(3 * var(--w))) + ); + } +} diff --git a/server/diypedals/web/styles/layouts/frame.css b/server/diypedals/web/styles/layouts/frame.css new file mode 100644 index 00000000..409518c1 --- /dev/null +++ b/server/diypedals/web/styles/layouts/frame.css @@ -0,0 +1,15 @@ +.f-frame { + aspect-ratio: var(--frame-ratio); + max-width: var(--frame-width, 100%); + overflow: hidden; + display: flex; + justify-content: center; + align-items: center; +} + +.f-frame > img { + /* Cover entire frame, image will be scaled if necessary */ + inline-size: 100%; + block-size: 100%; + object-fit: cover; +} diff --git a/server/diypedals/web/styles/layouts/grid.css b/server/diypedals/web/styles/layouts/grid.css new file mode 100644 index 00000000..52790e2f --- /dev/null +++ b/server/diypedals/web/styles/layouts/grid.css @@ -0,0 +1,10 @@ +.f-grid { + display: grid; + grid-gap: 1rem; +} + +@supports (width: min(250px, 100%)) { + .f-grid { + grid-template-columns: repeat(auto-fit, minmax(min(var(--grid-min, 250px), 100%), 1fr)); + } +} \ No newline at end of file diff --git a/server/diypedals/web/styles/layouts/index.css b/server/diypedals/web/styles/layouts/index.css new file mode 100644 index 00000000..3179b35a --- /dev/null +++ b/server/diypedals/web/styles/layouts/index.css @@ -0,0 +1,6 @@ +@import './box.css'; +@import './cluster.css'; +@import './container.css'; +@import './frame.css'; +@import './grid.css'; +@import './stack.css'; \ No newline at end of file diff --git a/server/diypedals/web/styles/layouts/stack.css b/server/diypedals/web/styles/layouts/stack.css new file mode 100644 index 00000000..b7dbb824 --- /dev/null +++ b/server/diypedals/web/styles/layouts/stack.css @@ -0,0 +1,7 @@ +.f-stack > * { + margin-bottom: 0; +} + +.f-stack > * + * { + margin-top: var(--stack-gap, calc(2 * var(--w))); +} diff --git a/server/diypedals/web/styles/styles.css b/server/diypedals/web/styles/styles.css new file mode 100644 index 00000000..cd28b5e6 --- /dev/null +++ b/server/diypedals/web/styles/styles.css @@ -0,0 +1,3 @@ +@import './variables.css'; +@import './defaults.css'; +@import './layouts/index.css'; diff --git a/server/diypedals/web/styles/variables.css b/server/diypedals/web/styles/variables.css new file mode 100644 index 00000000..bac78e9a --- /dev/null +++ b/server/diypedals/web/styles/variables.css @@ -0,0 +1,37 @@ +:root { + --v: 4px; + --w: 8px; + --1w: 8px; + --2w: 16px; + --4w: 32px; + + /* Colors */ + --surface: #edeff0; + --muted: #6b6b6b; + --danger: #ff0000; + --primary: #1866ac; + --background-default: white; + --text-on-background-default: #424242; + + /* Fonts */ + --font-family: monospace; + --font-base: 16px; + --font-size-default: 1rem; + --font-size-sm: 0.83rem; + --font-size-lg: 1.25rem; + --font-size-h1: 1.9rem; + --font-size-h2: 1.5rem; + --font-size-h3: 1.16rem; + --font-size-blockquote-mark: 1.6rem; + + /* Borders */ + --radius-sm: 5px; +} + +@media screen and (min-width: 900px) { + :root { + --font-size-h1: 2.9rem; + --font-size-h2: 2.1rem; + --font-size-h3: 1.5rem; + } +} diff --git a/server/diypedals/web/templates/base.jinja b/server/diypedals/web/templates/base.jinja new file mode 100644 index 00000000..d926e9a5 --- /dev/null +++ b/server/diypedals/web/templates/base.jinja @@ -0,0 +1,68 @@ + + + + + + + + {% block title %}diypedals.fm{% endblock title %} + + + + + + + + + + + + + + + + + + + + {# {% if settings.WEBMENTIONS_URL %} + + + {% endif %} #} + + {% block head %}{% endblock head %} + + + + + + {% block content %}{% endblock %} + + + + {% if settings.DEBUG %} + {{ hotreload.script(url_for('diypedals:hot-reload')) | safe }} + {% endif %} + + + + + {% block tail %}{% endblock %} + + diff --git a/server/diypedals/web/templates/views/build_reports/_detail_photos.jinja b/server/diypedals/web/templates/views/build_reports/_detail_photos.jinja new file mode 100644 index 00000000..5ac26238 --- /dev/null +++ b/server/diypedals/web/templates/views/build_reports/_detail_photos.jinja @@ -0,0 +1,7 @@ +
    + {% for photo in build_report.photos %} +
  • + {{ photo.alt }} +
  • + {% endfor %} +
diff --git a/server/diypedals/web/templates/views/build_reports/detail.jinja b/server/diypedals/web/templates/views/build_reports/detail.jinja new file mode 100644 index 00000000..e68e5b50 --- /dev/null +++ b/server/diypedals/web/templates/views/build_reports/detail.jinja @@ -0,0 +1,36 @@ +{% extends 'base.jinja' %} + +{% block title %} + {{ build_report.title }} - Guitar pedals - {{ super() }} +{% endblock %} + +{% block content %} +
+

{{ build_report.title }}

+ +

{{ build_report.description }}

+ +

Overview

+ +

Built: {{ build_report.build_date|dateformat }}

+ + {% if build_report.kit %} +

Kit: {{ build_report.kit.name }} ({{ build_report.kit.seller }})

+ {% endif %} + + {% if build_report.pcb %} +

PCB: {{ build_report.pcb.name }} ({{ build_report.pcb.seller }})

+ {% endif %} + +

Photos

+ + {% if photos_html %} + {{ photos_html|safe }} + {% else %} +
+ + +
+ {% endif %} +
+{% endblock content %} diff --git a/server/diypedals/web/templates/views/build_reports/list.jinja b/server/diypedals/web/templates/views/build_reports/list.jinja new file mode 100644 index 00000000..3dab525d --- /dev/null +++ b/server/diypedals/web/templates/views/build_reports/list.jinja @@ -0,0 +1,63 @@ +{% extends 'base.jinja' %} + +{% block content %} +
+

Build reports

+ +

Latest build reports

+ + {# Pour chaque pédale : la date, le type, le kit utilisé, les mods effectués, des photos intérieur / extérieur #} + +
    + {% for build_report in build_reports %} +
  • +
    + +
    +

    + + {{ build_report.title }} + +

    +

    + {{ build_report.description }} +

    +
    + + +
      + {% for category in build_report.categories %} +
    • {{ category|capitalize }}
    • + + {% endfor %} +
    +
    + + + + +
    +
  • + {% endfor %} +
+ +

Upcoming builds

+ +
    +
  • + Elevator Drive (Maestro MPF-1 / StoneDeaf PDF-2 clone) +
  • +
+ +

Tips and tutorials

+ +
    +
  • Getting started with DIY pedal building
  • +
  • Finding components, kits and enclosures in the EU
  • +
  • Drilling guitar pedal enclosures without a drill press
  • +
  • Planning pedal builds using Inkscape
  • +
  • Easy wiring of 9mm potentiometers
  • +
  • An introduction to guitar pedal modding
  • +
+
+{% endblock content %} \ No newline at end of file diff --git a/server/diypedals/web/templates/views/index.jinja b/server/diypedals/web/templates/views/index.jinja new file mode 100644 index 00000000..17f32fd0 --- /dev/null +++ b/server/diypedals/web/templates/views/index.jinja @@ -0,0 +1,5 @@ +{% extends 'base.jinja' %} + +{% block content %} +

diypedals.fm

+{% endblock content %} diff --git a/server/diypedals/web/templating.py b/server/diypedals/web/templating.py new file mode 100644 index 00000000..df4ec0f5 --- /dev/null +++ b/server/diypedals/web/templating.py @@ -0,0 +1,33 @@ +import datetime as dt + +import jinja2 +from starlette.exceptions import HTTPException +from starlette.templating import Jinja2Templates + +from .. import settings +from ..infrastructure.urls import get_absolute_path, to_production_url +from .reload import HotReload + + +class Templates(Jinja2Templates): + def __init__(self, hotreload: HotReload) -> None: + super().__init__(directory=str(settings.TEMPLATES_DIR)) + + def _absolute_url(obj): + return to_production_url(get_absolute_path(obj)) + + self.env.globals["now"] = dt.datetime.now + self.env.globals["settings"] = settings + self.env.globals["hotreload"] = hotreload + self.env.filters["absolute_url"] = _absolute_url + self.env.filters["dateformat"] = _dateformat + self.env.filters["isoformat"] = lambda value: value.isoformat() + + def from_string(self, source: str) -> jinja2.Template: + return self.env.from_string(source) + + +def _dateformat(value: dt.date | str) -> str: + if isinstance(value, str): + value = dt.date.fromisoformat(value) + return value.strftime("%b %d, %Y") diff --git a/server/diypedals/web/views.py b/server/diypedals/web/views.py new file mode 100644 index 00000000..b0f64d5c --- /dev/null +++ b/server/diypedals/web/views.py @@ -0,0 +1,54 @@ +from starlette.endpoints import HTTPEndpoint +from starlette.requests import Request +from starlette.responses import Response + +from .. import settings +from ..di import resolve +from ..domain.repositories import BuildReportRepository +from .templating import Templates + + +class Home(HTTPEndpoint): + async def get(self, request: Request) -> Response: + templates = resolve(Templates) + + return templates.TemplateResponse(request, "views/index.jinja") + + +class BuildReportList(HTTPEndpoint): + async def get(self, request: Request) -> Response: + templates = resolve(Templates) + build_report_repository = resolve(BuildReportRepository) + + build_reports = await build_report_repository.find_all() + + context = { + "build_reports": build_reports, + } + + return templates.TemplateResponse( + request, "views/build_reports/list.jinja", context + ) + + +class BuildReportDetail(HTTPEndpoint): + async def get(self, request: Request) -> Response: + templates = resolve(Templates) + build_report_repository = resolve(BuildReportRepository) + + slug = request.path_params["slug"] + + build_report = await build_report_repository.find_one(slug=slug) + + context = { + "build_report": build_report, + } + + if request.query_params.get("photos") == "true": + context["photos_html"] = templates.env.get_template( + "views/build_reports/_detail_photos.jinja" + ).render(context) + + return templates.TemplateResponse( + request, "views/build_reports/detail.jinja", context + ) diff --git a/server/main.py b/server/main.py index d057555d..090970c9 100644 --- a/server/main.py +++ b/server/main.py @@ -3,9 +3,11 @@ from . import settings from .di import bootstrap +from .diypedals.di import bootstrap as diypedals_bootstrap from .web.app import create_app bootstrap() +diypedals_bootstrap() app = create_app() diff --git a/server/web/app.py b/server/web/app.py index a904b92f..0446848b 100644 --- a/server/web/app.py +++ b/server/web/app.py @@ -6,6 +6,7 @@ from .. import settings from ..di import resolve +from ..diypedals.web.app import create_app as create_diypedals from ..infrastructure.database import InMemoryDatabase from . import views from .middleware import middleware @@ -17,7 +18,9 @@ def create_app() -> ASGIApp: db = resolve(InMemoryDatabase) hotreload = resolve(HotReload) - routes = get_routes() + diypedals = create_diypedals() + + routes = get_routes(diypedals.routes) @asynccontextmanager async def lifespan(app: Starlette) -> AsyncIterator[None]: @@ -26,7 +29,8 @@ async def lifespan(app: Starlette) -> AsyncIterator[None]: if settings.DEBUG: # pragma: no cover await hotreload.startup() - yield + async with diypedals.lifespan(): + yield if settings.DEBUG: # pragma: no cover await hotreload.shutdown() diff --git a/server/web/routes.py b/server/web/routes.py index 15cfa794..07732d7d 100644 --- a/server/web/routes.py +++ b/server/web/routes.py @@ -1,4 +1,5 @@ -from starlette.routing import BaseRoute, Host, Mount, Route, WebSocketRoute +from starlette.routing import BaseRoute, Host, Mount, Route, Router, WebSocketRoute +from starlette.types import ASGIApp from .. import settings from ..di import resolve @@ -9,7 +10,7 @@ from .statics import CachedStaticFiles -def get_routes() -> list[BaseRoute]: +def get_routes(diypedals_routes: list[BaseRoute]) -> list[BaseRoute]: static = CachedStaticFiles( directory=str(settings.STATIC_DIR), max_age=7 * 86400, # 7 days @@ -33,6 +34,7 @@ def get_routes() -> list[BaseRoute]: app=legacy.DomainRedirect("florimond.dev", root_path="/blog"), name="legacy:blog_dot_dev", ), + Mount("/diypedals", routes=diypedals_routes, name="diypedals"), Route("/error/", views.error), Route("/blog/", views.legacy_blog_home, name="legacy:blog_home"), Mount(settings.STATIC_ROOT, static, name="static"), From ad860146ab48529a1bea4e24235d6718c40a797b Mon Sep 17 00:00:00 2001 From: florimondmanca Date: Wed, 13 Mar 2024 21:35:24 +0100 Subject: [PATCH 2/6] Add category sidebar, pages and links --- Makefile | 15 ++++-- server/diypedals/domain/entities.py | 2 +- server/diypedals/domain/repositories.py | 10 +++- server/diypedals/infrastructure/cache.py | 6 +-- server/diypedals/infrastructure/database.py | 27 ++++++++-- .../diypedals/infrastructure/repositories.py | 9 ++-- server/diypedals/infrastructure/webdav.py | 27 +++++----- server/diypedals/settings.py | 6 +-- server/diypedals/web/app.py | 4 +- server/diypedals/web/reload.py | 4 +- server/diypedals/web/routes.py | 5 ++ server/diypedals/web/styles/defaults.css | 5 +- server/diypedals/web/styles/layouts/grid.css | 2 +- server/diypedals/web/styles/layouts/index.css | 1 + .../diypedals/web/styles/layouts/sidebar.css | 15 ++++++ server/diypedals/web/templates/base.jinja | 4 +- .../views/build_reports/_category_list.jinja | 15 ++++++ .../build_reports/_category_sidebar.jinja | 14 +++++ .../views/build_reports/_detail_photos.jinja | 4 +- .../views/build_reports/_list_view.jinja | 29 +++++++++++ .../build_reports/categories/detail.jinja | 25 +++++++++ .../views/build_reports/detail.jinja | 42 +++++++++++---- .../templates/views/build_reports/list.jinja | 51 ++++++------------- server/diypedals/web/templating.py | 4 +- server/diypedals/web/views.py | 32 +++++++++--- tools/pybuild.py | 8 +-- 26 files changed, 266 insertions(+), 100 deletions(-) create mode 100644 server/diypedals/web/styles/layouts/sidebar.css create mode 100644 server/diypedals/web/templates/views/build_reports/_category_list.jinja create mode 100644 server/diypedals/web/templates/views/build_reports/_category_sidebar.jinja create mode 100644 server/diypedals/web/templates/views/build_reports/_list_view.jinja create mode 100644 server/diypedals/web/templates/views/build_reports/categories/detail.jinja diff --git a/Makefile b/Makefile index 954e4500..be769f5a 100644 --- a/Makefile +++ b/Makefile @@ -34,11 +34,14 @@ install-python: venv ## build: # Build assets - make pybuild + make pybuild-www + make pybuild-diypedals make messagesc -pybuild: +pybuild-www: ${bin}python -m tools.pybuild ./server/web/styles/styles.css --outdir ./server/web/static/css ${ARGS} + +pybuild-diypedals: ${bin}python -m tools.pybuild ./server/diypedals/web/styles/styles.css --outdir ./server/diypedals/web/static/css ${ARGS} ## @@ -49,12 +52,16 @@ pybuild: start: # Run servers make -j start-uvicorn start-assets + make -j start-uvicorn start-assets-www start-assets-diypedals start-uvicorn: PYTHONUNBUFFERED=1 ${bin}python -m server.main 2>&1 | ${bin}python -m tools.colorprefix blue [server] -start-assets: - PYTHONUNBUFFERED=1 make pybuild ARGS="--watch" 2>&1 | ${bin}python -m tools.colorprefix yellow [assets] +start-assets-www: + PYTHONUNBUFFERED=1 make pybuild-www ARGS="--watch" 2>&1 | ${bin}python -m tools.colorprefix yellow [assets-www] + +start-assets-diypedals: + PYTHONUNBUFFERED=1 make pybuild-diypedals ARGS="--watch" 2>&1 | ${bin}python -m tools.colorprefix red [assets-diypedals] ## ## ---------------- diff --git a/server/diypedals/domain/entities.py b/server/diypedals/domain/entities.py index b850e920..5c26ab31 100644 --- a/server/diypedals/domain/entities.py +++ b/server/diypedals/domain/entities.py @@ -27,7 +27,7 @@ class BuildReport: title: str slug: str description: str - categories: list + categories: list[str] build_date: dt.date status: str photos: list[Photo] = field(default_factory=list) diff --git a/server/diypedals/domain/repositories.py b/server/diypedals/domain/repositories.py index c56cb63b..650c9490 100644 --- a/server/diypedals/domain/repositories.py +++ b/server/diypedals/domain/repositories.py @@ -5,8 +5,14 @@ class BuildReportRepository: async def save(self, build_report: BuildReport) -> None: raise NotImplementedError # pragma: no cover - async def find_all(self) -> list[BuildReport]: + async def find_all(self, *, category: str | None = None) -> list[BuildReport]: raise NotImplementedError # pragma: no cover - async def find_one(self, *, slug: str) -> BuildReport: + async def find_one(self, *, slug: str) -> BuildReport | None: + raise NotImplementedError # pragma: no cover + + async def get_unique_categories(self) -> dict[str, int]: + raise NotImplementedError # pragma: no cover + + async def find_all_with_category(self, category: str) -> list[BuildReport]: raise NotImplementedError # pragma: no cover diff --git a/server/diypedals/infrastructure/cache.py b/server/diypedals/infrastructure/cache.py index 6c0afd6d..a412bbde 100644 --- a/server/diypedals/infrastructure/cache.py +++ b/server/diypedals/infrastructure/cache.py @@ -1,8 +1,6 @@ import json from pathlib import Path -from typing import Callable - -from .. import settings +from typing import Any, Callable class DiskCache: @@ -19,7 +17,7 @@ def _get_cache(self) -> dict: def _write_cache(self, cache: dict) -> None: self._file.write_text(json.dumps(cache)) - async def get(self, key: str, fetch_func: Callable): + async def get(self, key: str, fetch_func: Callable) -> Any: cache = self._get_cache() try: diff --git a/server/diypedals/infrastructure/database.py b/server/diypedals/infrastructure/database.py index a767d067..38067818 100644 --- a/server/diypedals/infrastructure/database.py +++ b/server/diypedals/infrastructure/database.py @@ -35,11 +35,20 @@ async def _load(self) -> None: await load_build_reports() - def find_all_build_reports(self) -> list[BuildReport]: + def find_all_build_reports( + self, *, category: str | None = None + ) -> list[BuildReport]: + queryset = (build_report for build_report in self._data.build_reports.values()) + + if category is not None: + queryset = ( + build_report + for build_report in queryset + if category in build_report.categories + ) + return sorted( - self._data.build_reports.values(), - key=lambda build_report: build_report.build_date, - reverse=True, + queryset, key=lambda build_report: build_report.build_date, reverse=True ) def find_one_build_report(self, slug: str) -> BuildReport | None: @@ -47,3 +56,13 @@ def find_one_build_report(self, slug: str) -> BuildReport | None: def insert_build_report(self, build_report: BuildReport) -> None: self._data.build_reports[build_report.slug] = build_report + + def find_unique_build_report_categories(self) -> dict[str, int]: + categories_with_count: dict[str, int] = {} + + for build_report in self._data.build_reports.values(): + for category in build_report.categories: + categories_with_count.setdefault(category, 0) + categories_with_count[category] += 1 + + return categories_with_count diff --git a/server/diypedals/infrastructure/repositories.py b/server/diypedals/infrastructure/repositories.py index 9ca1083d..0a834d70 100644 --- a/server/diypedals/infrastructure/repositories.py +++ b/server/diypedals/infrastructure/repositories.py @@ -10,8 +10,11 @@ def __init__(self, db: InMemoryDatabase) -> None: async def save(self, build_report: BuildReport) -> None: self._db.insert_build_report(build_report) - async def find_all(self) -> list[BuildReport]: - return self._db.find_all_build_reports() + async def find_all(self, *, category: str | None = None) -> list[BuildReport]: + return self._db.find_all_build_reports(category=category) - async def find_one(self, *, slug: str) -> BuildReport: + async def find_one(self, *, slug: str) -> BuildReport | None: return self._db.find_one_build_report(slug) + + async def get_unique_categories(self) -> dict[str, int]: + return self._db.find_unique_build_report_categories() diff --git a/server/diypedals/infrastructure/webdav.py b/server/diypedals/infrastructure/webdav.py index fe6e38b1..6bf7e17a 100644 --- a/server/diypedals/infrastructure/webdav.py +++ b/server/diypedals/infrastructure/webdav.py @@ -39,13 +39,13 @@ async def fetch_all(self) -> AsyncIterator[BuildReport]: xml = ET.fromstring(r.text) ns = {"d": "DAV:"} - build_reports = [] - for item in itertools.islice(xml, 1, None): # First is the folder itself if item.find(".//d:resourcetype/d:collection", ns) is None: continue href = item.find(".//d:href", ns) + assert href is not None and href.text is not None + photos_url = webdav_url.copy_with(path=href.text + "entry.json") r = await self._http.request("GET", photos_url) r.raise_for_status() @@ -80,20 +80,20 @@ async def fetch_all(self) -> AsyncIterator[BuildReport]: ): continue - if not photo_item.find(".//d:getcontenttype", ns).text.startswith( - "image/" - ): + content_type = photo_item.find(".//d:getcontenttype", ns) + assert content_type is not None and content_type.text is not None + + if not content_type.text.startswith("image/"): continue - async def _fetch_photo(): + async def _fetch_photo() -> dict: # Browser won't be able to download the image as it is behind authentication. # Need to download the image and serve it as base64. # See: https://stackoverflow.com/a/62305417 - photo_url = webdav_url.copy_with( - path=photo_item.find(".//d:href", ns).text - ) + href = photo_item.find(".//d:href", ns) + assert href is not None and href.text is not None + photo_url = webdav_url.copy_with(path=href.text) photo_response = await self._http.request("GET", photo_url) - photo_content = photo_response.text photo_src = "data:%s;base64,%s" % ( photo_response.headers["content-type"], @@ -102,8 +102,11 @@ async def _fetch_photo(): return {"src": photo_src, "alt": "Photo"} - photo_etag = photo_item.find(".//d:getetag", ns).text - photo = Photo(**(await self._cache.get(photo_etag, _fetch_photo))) + photo_etag = photo_item.find(".//d:getetag", ns) + assert photo_etag is not None and photo_etag.text is not None + photo = Photo( + **(await self._cache.get(photo_etag.text, _fetch_photo)) + ) photos.append(photo) yield BuildReport( diff --git a/server/diypedals/settings.py b/server/diypedals/settings.py index b2d101c0..dd8f44c7 100644 --- a/server/diypedals/settings.py +++ b/server/diypedals/settings.py @@ -17,9 +17,9 @@ STATIC_DIR = HERE / "web" / "static" TEMPLATES_DIR = HERE / "web" / "templates" -WEBDAV_USERNAME = config("DIYPEDALS_WEBDAV_USERNAME", default=None) -WEBDAV_PASSWORD: Secret = config("DIYPEDALS_WEBDAV_PASSWORD", cast=Secret, default=None) +WEBDAV_USERNAME = config("DIYPEDALS_WEBDAV_USERNAME") +WEBDAV_PASSWORD: Secret = config("DIYPEDALS_WEBDAV_PASSWORD", cast=Secret) -BUILD_REPORTS_WEBDAV_URL = config("DIYPEDALS_BUILD_REPORTS_WEBDAV_URL", default=None) +BUILD_REPORTS_WEBDAV_URL = config("DIYPEDALS_BUILD_REPORTS_WEBDAV_URL") BUILD_REPORTS_IMG_CDIR = STATIC_DIR / "diypedals" / "img" / "build_reports" BUILD_REPORTS_CACHE_DIR = HERE / "cache" diff --git a/server/diypedals/web/app.py b/server/diypedals/web/app.py index cf2a4747..a693d8f7 100644 --- a/server/diypedals/web/app.py +++ b/server/diypedals/web/app.py @@ -1,7 +1,7 @@ from contextlib import asynccontextmanager +from typing import AsyncIterator from starlette.routing import BaseRoute -from starlette.types import ASGIApp from .. import settings from ..di import resolve @@ -15,7 +15,7 @@ def __init__(self) -> None: self._routes = get_routes() @asynccontextmanager - async def lifespan(self): + async def lifespan(self) -> AsyncIterator[None]: db = resolve(InMemoryDatabase) hotreload = resolve(HotReload) diff --git a/server/diypedals/web/reload.py b/server/diypedals/web/reload.py index e2fd5086..b4478344 100644 --- a/server/diypedals/web/reload.py +++ b/server/diypedals/web/reload.py @@ -11,7 +11,7 @@ class HotReload(arel.HotReload): def __init__(self) -> None: super().__init__( paths=[ - arel.Path(settings.TEMPLATES_DIR), - arel.Path(settings.STATIC_DIR), + arel.Path(str(settings.TEMPLATES_DIR)), + arel.Path(str(settings.STATIC_DIR)), ] ) diff --git a/server/diypedals/web/routes.py b/server/diypedals/web/routes.py index 65ddc7c9..7d4b3cec 100644 --- a/server/diypedals/web/routes.py +++ b/server/diypedals/web/routes.py @@ -31,6 +31,11 @@ def get_routes() -> list[BaseRoute]: views.BuildReportDetail, name="build_reports:detail", ), + Route( + "/build-reports/categories/{category:str}", + views.BuildReportCategoryDetail, + name="build_reports:categories:detail", + ), ] if settings.DEBUG: # pragma: no cover diff --git a/server/diypedals/web/styles/defaults.css b/server/diypedals/web/styles/defaults.css index c62b4504..a4a482b7 100644 --- a/server/diypedals/web/styles/defaults.css +++ b/server/diypedals/web/styles/defaults.css @@ -51,7 +51,6 @@ ul { a { color: inherit; - text-decoration: none; } a:visited { @@ -125,3 +124,7 @@ td { border: 1px solid #dfe2e5; padding: 0.6em 1em; } + +[aria-current="page"] { + font-weight: bold; +} \ No newline at end of file diff --git a/server/diypedals/web/styles/layouts/grid.css b/server/diypedals/web/styles/layouts/grid.css index 52790e2f..458741be 100644 --- a/server/diypedals/web/styles/layouts/grid.css +++ b/server/diypedals/web/styles/layouts/grid.css @@ -5,6 +5,6 @@ @supports (width: min(250px, 100%)) { .f-grid { - grid-template-columns: repeat(auto-fit, minmax(min(var(--grid-min, 250px), 100%), 1fr)); + grid-template-columns: repeat(auto-fit, minmax(min(var(--grid-min, 250px), 100%), var(--grid-max, 1fr))); } } \ No newline at end of file diff --git a/server/diypedals/web/styles/layouts/index.css b/server/diypedals/web/styles/layouts/index.css index 3179b35a..a0439a12 100644 --- a/server/diypedals/web/styles/layouts/index.css +++ b/server/diypedals/web/styles/layouts/index.css @@ -3,4 +3,5 @@ @import './container.css'; @import './frame.css'; @import './grid.css'; +@import './sidebar.css'; @import './stack.css'; \ No newline at end of file diff --git a/server/diypedals/web/styles/layouts/sidebar.css b/server/diypedals/web/styles/layouts/sidebar.css new file mode 100644 index 00000000..97178ddf --- /dev/null +++ b/server/diypedals/web/styles/layouts/sidebar.css @@ -0,0 +1,15 @@ +.f-with-sidebar-left, .f-with-sidebar-right { + display: flex; + flex-wrap: wrap; + gap: var(--sidebar-gap, 0); +} + +.f-with-sidebar-left > :first-child, .f-with-sidebar-right > :last-child { + flex-grow: 1; +} + +.f-with-sidebar-left > :last-child, .f-with-sidebar-right > :first-child { + flex-basis: 0; + flex-grow: 999; + min-inline-size: var(--sidebar-content-min, 50%); +} diff --git a/server/diypedals/web/templates/base.jinja b/server/diypedals/web/templates/base.jinja index d926e9a5..87819e40 100644 --- a/server/diypedals/web/templates/base.jinja +++ b/server/diypedals/web/templates/base.jinja @@ -26,7 +26,7 @@ - + {# {% if settings.WEBMENTIONS_URL %} @@ -36,7 +36,7 @@ {% block head %}{% endblock head %} - +