diff --git a/Makefile b/Makefile
index def07bd8..3f00b4ca 100644
--- a/Makefile
+++ b/Makefile
@@ -34,12 +34,16 @@ 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}
+
##
## ----------------
## Serving
@@ -47,13 +51,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/.gitignore b/server/diypedals/.gitignore
new file mode 100644
index 00000000..d7e1b6bc
--- /dev/null
+++ b/server/diypedals/.gitignore
@@ -0,0 +1,2 @@
+cache/
+web/static/img/build_reports/
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..2238347b
--- /dev/null
+++ b/server/diypedals/domain/entities.py
@@ -0,0 +1,37 @@
+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:
+ filename: str
+ content_type: str
+ alt: str
+
+
+@dataclass(frozen=True)
+class BuildReport:
+ title: str
+ slug: str
+ description: str
+ categories: list[str]
+ build_date: dt.date
+ status: str
+ thumbnail: Photo
+ 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..650c9490
--- /dev/null
+++ b/server/diypedals/domain/repositories.py
@@ -0,0 +1,18 @@
+from .entities import BuildReport
+
+
+class BuildReportRepository:
+ async def save(self, build_report: BuildReport) -> None:
+ raise NotImplementedError # pragma: no cover
+
+ async def find_all(self, *, category: str | None = None) -> list[BuildReport]:
+ raise NotImplementedError # pragma: no cover
+
+ 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/__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..43849981
--- /dev/null
+++ b/server/diypedals/infrastructure/cache.py
@@ -0,0 +1,45 @@
+import json
+import mimetypes
+from pathlib import Path
+from typing import Any, Callable
+
+
+class DiskCache:
+ def __init__(self, directory: Path) -> None:
+ self._dir = directory
+ self._dir.mkdir(exist_ok=True)
+ self._file = self._dir / "cache.json"
+ self._data: dict = {}
+
+ try:
+ self._data = json.loads(self._file.read_text())
+ except FileNotFoundError:
+ self._file.write_text("{}")
+
+ def _write_cache(self) -> None:
+ self._file.write_text(json.dumps(self._data))
+
+ async def get(self, key: str, fetch_func: Callable) -> Any:
+ try:
+ return self._data[key]
+ except KeyError:
+ value = await fetch_func()
+ self._data[key] = value
+ self._write_cache()
+ return value
+
+ async def get_file(
+ self, path: Path | str, fetch_func: Callable
+ ) -> tuple[bytes, str]:
+ path = Path(path)
+
+ try:
+ content = path.read_bytes()
+ except FileNotFoundError:
+ content, media_type = await fetch_func()
+ path.write_bytes(content)
+ else:
+ media_type = mimetypes.guess_type(path.name)
+ assert media_type is not None
+
+ return content, media_type
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..38067818
--- /dev/null
+++ b/server/diypedals/infrastructure/database.py
@@ -0,0 +1,68 @@
+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, *, 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(
+ queryset, 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
+
+ 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/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..0a834d70
--- /dev/null
+++ b/server/diypedals/infrastructure/repositories.py
@@ -0,0 +1,20 @@
+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, *, category: str | None = None) -> list[BuildReport]:
+ return self._db.find_all_build_reports(category=category)
+
+ 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/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..3bce33cc
--- /dev/null
+++ b/server/diypedals/infrastructure/webdav.py
@@ -0,0 +1,220 @@
+import datetime as dt
+import http
+import mimetypes
+import xml.etree.ElementTree as ET
+from dataclasses import dataclass
+from typing import Any, AsyncIterator
+
+import httpx
+from PIL import Image
+
+from .. import settings
+from ..domain.entities import BuildReport, Kit, Pcb, Photo
+from .cache import DiskCache
+
+
+@dataclass(frozen=True)
+class _Folder:
+ href: str
+ etag: str
+ display_name: str
+
+
+@dataclass(frozen=True)
+class _PhotoFile:
+ media_type: str
+ href: str
+ etag: str
+ display_name: str
+
+
+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, read=15),
+ )
+ # https://docs.nextcloud.com/server/latest/developer_manual/client_apis/WebDAV/basic.html
+ self._url = httpx.URL(settings.BUILD_REPORTS_WEBDAV_URL)
+ self._images_dir = settings.BUILD_REPORTS_IMAGES_DIR
+ self._images_dir.mkdir(exist_ok=True)
+
+ async def fetch_all(self) -> AsyncIterator[BuildReport]:
+ folders = await self._list_folders()
+
+ for folder in folders:
+ entry = await self._read_entry(folder)
+ photos = await self._read_photos(folder)
+ thumbnail = _make_thumbnail(photos[0])
+
+ yield BuildReport(
+ title=entry["title"],
+ slug=entry["slug"],
+ description=entry["description"],
+ categories=entry["categories"],
+ build_date=dt.date.fromisoformat(entry["build_date"]),
+ status=entry["status"],
+ thumbnail=thumbnail,
+ photos=photos,
+ kit=Kit(**entry["kit"]) if entry.get("kit") else None,
+ pcb=Pcb(**entry["pcb"]) if entry.get("pcb") else None,
+ )
+
+ async def _list_folders(self) -> list[_Folder]:
+ body = """
+
+
+
+
+
+
+
+ """
+ response = await self._http.request("PROPFIND", self._url, content=body)
+ response.raise_for_status()
+ assert response.status_code == http.HTTPStatus.MULTI_STATUS.value
+
+ xml = ET.fromstring(response.text)
+
+ folders = []
+
+ for i, item in enumerate(xml):
+ if i == 0:
+ # Skip the folder itself
+ continue
+
+ if item.find(".//d:resourcetype/d:collection", {"d": "DAV:"}) is None:
+ continue
+
+ href_el = item.find(".//d:href", {"d": "DAV:"})
+ assert href_el is not None and href_el.text is not None
+
+ etag_el = item.find(".//d:getetag", {"d": "DAV:"})
+ assert etag_el is not None and etag_el.text is not None
+
+ display_name_el = item.find(".//d:displayname", {"d": "DAV:"})
+ assert display_name_el is not None and display_name_el.text is not None
+
+ folder = _Folder(
+ href=href_el.text,
+ etag=etag_el.text,
+ display_name=display_name_el.text,
+ )
+
+ folders.append(folder)
+
+ return folders
+
+ async def _read_entry(self, folder: _Folder) -> dict[str, Any]:
+ async def fetch() -> dict:
+ url = str(self._url.copy_with(path=folder.href + "entry.json"))
+ r = await self._http.request("GET", url)
+ r.raise_for_status()
+ return r.json()
+
+ return await self._cache.get(f"entry-{folder.etag}", lambda: fetch())
+
+ async def _list_photo_files(self, folder: _Folder) -> list[_PhotoFile]:
+ body = """
+
+
+
+
+
+
+
+
+ """
+ url = str(self._url.copy_with(path=folder.href + "photos"))
+ response = await self._http.request("PROPFIND", url, content=body)
+ response.raise_for_status()
+ assert response.status_code == http.HTTPStatus.MULTI_STATUS.value
+
+ xml = ET.fromstring(response.text)
+
+ photo_files = []
+
+ for i, item in enumerate(xml):
+ if i == 0:
+ # Skip the folder itself
+ continue
+
+ if item.find(".//d:resourcetype/d:collection", {"d": "DAV:"}) is not None:
+ continue
+
+ media_type_el = item.find(".//d:getcontenttype", {"d": "DAV:"})
+ assert media_type_el is not None and media_type_el.text is not None
+
+ if not media_type_el.text.startswith("image/"):
+ continue
+
+ href_el = item.find(".//d:href", {"d": "DAV:"})
+ assert href_el is not None and href_el.text is not None
+
+ etag_el = item.find(".//d:getetag", {"d": "DAV:"})
+ assert etag_el is not None and etag_el.text is not None
+
+ display_name_el = item.find(".//d:displayname", {"d": "DAV:"})
+ assert display_name_el is not None and display_name_el.text is not None
+
+ photo_file = _PhotoFile(
+ media_type=media_type_el.text,
+ href=href_el.text,
+ etag=etag_el.text,
+ display_name=folder.display_name + "-" + display_name_el.text,
+ )
+
+ photo_files.append(photo_file)
+
+ return photo_files
+
+ async def _read_photos(self, folder: _Folder) -> list[Photo]:
+ async def fetch() -> list[dict]:
+ return [
+ await self._read_photo_serializable(photo_file)
+ for photo_file in await self._list_photo_files(folder)
+ ]
+
+ attrs = await self._cache.get(f"photos-{folder.etag}", lambda: fetch())
+
+ return [Photo(**attr) for attr in attrs]
+
+ async def _read_photo_serializable(self, photo_file: _PhotoFile) -> dict:
+ async def fetch() -> dict:
+ static_path = self._images_dir / photo_file.display_name
+
+ try:
+ content = static_path.read_bytes()
+ media_type, _ = mimetypes.guess_type(static_path.name)
+ assert media_type is not None
+ except FileNotFoundError:
+ url = self._url.copy_with(path=photo_file.href)
+ response = await self._http.request("GET", url)
+ content = response.content
+ media_type = response.headers["Content-Type"]
+ static_path.write_bytes(content)
+
+ return {
+ "filename": str(static_path.relative_to(settings.STATIC_DIR)),
+ "content_type": media_type,
+ "alt": "Photo",
+ }
+
+ return await self._cache.get(photo_file.etag, lambda: fetch())
+
+
+def _make_thumbnail(photo: Photo) -> Photo:
+ img = Image.open(settings.STATIC_DIR / photo.filename)
+
+ img.thumbnail((360, 240))
+
+ img_path = settings.STATIC_DIR / photo.filename
+ thumbnail_path = img_path.parent / f"{img_path.stem}_thumbnail{img_path.suffix}"
+ img.save(thumbnail_path, format="JPEG")
+
+ return Photo(
+ filename=str(thumbnail_path.relative_to(settings.STATIC_DIR)),
+ content_type="image/jpeg",
+ alt="Thumbnail", # TODO
+ )
diff --git a/server/diypedals/settings.py b/server/diypedals/settings.py
new file mode 100644
index 00000000..07489203
--- /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")
+WEBDAV_PASSWORD: Secret = config("DIYPEDALS_WEBDAV_PASSWORD", cast=Secret)
+
+BUILD_REPORTS_WEBDAV_URL = config("DIYPEDALS_BUILD_REPORTS_WEBDAV_URL")
+BUILD_REPORTS_IMAGES_DIR = STATIC_DIR / "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..a693d8f7
--- /dev/null
+++ b/server/diypedals/web/app.py
@@ -0,0 +1,38 @@
+from contextlib import asynccontextmanager
+from typing import AsyncIterator
+
+from starlette.routing import BaseRoute
+
+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) -> AsyncIterator[None]:
+ 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..b4478344
--- /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(str(settings.TEMPLATES_DIR)),
+ arel.Path(str(settings.STATIC_DIR)),
+ ]
+ )
diff --git a/server/diypedals/web/routes.py b/server/diypedals/web/routes.py
new file mode 100644
index 00000000..7d4b3cec
--- /dev/null
+++ b/server/diypedals/web/routes.py
@@ -0,0 +1,44 @@
+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",
+ ),
+ Route(
+ "/build-reports/categories/{category:str}",
+ views.BuildReportCategoryDetail,
+ name="build_reports:categories: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 00000000..0afc7508
Binary files /dev/null and b/server/diypedals/web/static/apple-touch-icon.png differ
diff --git a/server/diypedals/web/static/css/.gitkeep b/server/diypedals/web/static/css/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/server/diypedals/web/static/favicon.ico b/server/diypedals/web/static/favicon.ico
new file mode 100644
index 00000000..d47db764
Binary files /dev/null and b/server/diypedals/web/static/favicon.ico differ
diff --git a/server/diypedals/web/static/img/build_reports/.gitkeep b/server/diypedals/web/static/img/build_reports/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/server/diypedals/web/static/manifest.json b/server/diypedals/web/static/manifest.json
new file mode 100644
index 00000000..cd57790c
--- /dev/null
+++ b/server/diypedals/web/static/manifest.json
@@ -0,0 +1,21 @@
+{
+ "name": "diypedals.fm: DIY Guitar Pedals by Florimond MAnca",
+ "short_name": "diypedals.fm",
+ "theme_color": "#59c6b6",
+ "background_color": "#354249",
+ "display": "standalone",
+ "scope": "/",
+ "start_url": "/",
+ "icons": [
+ {
+ "src": "img/icon-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "img/icon-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ }
+ ]
+}
diff --git a/server/diypedals/web/static/robots.txt b/server/diypedals/web/static/robots.txt
new file mode 100644
index 00000000..eb053628
--- /dev/null
+++ b/server/diypedals/web/static/robots.txt
@@ -0,0 +1,2 @@
+User-agent: *
+Disallow:
diff --git a/server/diypedals/web/statics.py b/server/diypedals/web/statics.py
new file mode 100644
index 00000000..0718b346
--- /dev/null
+++ b/server/diypedals/web/statics.py
@@ -0,0 +1,19 @@
+from typing import Any
+
+from starlette.responses import Response
+from starlette.staticfiles import StaticFiles
+
+
+class CachedStaticFiles(StaticFiles):
+ def __init__(self, *args: Any, max_age: int, **kwargs: Any) -> 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..6f057909
--- /dev/null
+++ b/server/diypedals/web/styles/defaults.css
@@ -0,0 +1,137 @@
+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;
+ display: flex;
+ flex-flow: column;
+ min-height: 100vh;
+ align-self: stretch;
+}
+
+h1 {
+ font-family: var(--font-heading);
+ font-size: var(--font-size-h1);
+ font-weight: bold;
+ margin-top: 0;
+ margin-bottom: var(--w);
+ line-height: 1.25;
+}
+
+h2 {
+ font-family: var(--font-heading);
+ font-size: var(--font-size-h2);
+ font-weight: normal;
+ margin-top: 0;
+ margin-bottom: var(--w);
+}
+
+h3, .f-h3 {
+ font-family: var(--font-heading);
+ 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;
+}
+
+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;
+}
+
+[aria-current="page"] {
+ font-weight: bold;
+}
\ No newline at end of file
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..23e1e21c
--- /dev/null
+++ b/server/diypedals/web/styles/layouts/container.css
@@ -0,0 +1,14 @@
+.f-container {
+ max-width: var(--container-width, 100ch);
+ 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..458741be
--- /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%), 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
new file mode 100644
index 00000000..a0439a12
--- /dev/null
+++ b/server/diypedals/web/styles/layouts/index.css
@@ -0,0 +1,7 @@
+@import './box.css';
+@import './cluster.css';
+@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/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/reset.css b/server/diypedals/web/styles/reset.css
new file mode 100644
index 00000000..fc50671f
--- /dev/null
+++ b/server/diypedals/web/styles/reset.css
@@ -0,0 +1,70 @@
+/* https://www.joshwcomeau.com/css/custom-css-reset/ */
+
+/*
+ 1. Use a more-intuitive box-sizing model.
+*/
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+/*
+ 2. Remove default margin
+ */
+* {
+ margin: 0;
+}
+
+/*
+ Typographic tweaks!
+ 3. Add accessible line-height
+ 4. Improve text rendering
+ */
+body {
+ line-height: 1.5;
+ -webkit-font-smoothing: antialiased;
+}
+
+/*
+ 5. Improve media defaults
+ */
+img,
+picture,
+video,
+canvas,
+svg {
+ display: block;
+ max-width: 100%;
+}
+
+/*
+ 6. Remove built-in form typography styles
+ */
+input,
+button,
+textarea,
+select {
+ font: inherit;
+}
+
+/*
+ 7. Avoid text overflows
+ */
+p,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ overflow-wrap: break-word;
+}
+
+/*
+ 8. Create a root stacking context
+ */
+#root,
+#__next {
+ isolation: isolate;
+}
\ No newline at end of file
diff --git a/server/diypedals/web/styles/styles.css b/server/diypedals/web/styles/styles.css
new file mode 100644
index 00000000..ae87e376
--- /dev/null
+++ b/server/diypedals/web/styles/styles.css
@@ -0,0 +1,5 @@
+@import './reset.css';
+@import './variables.css';
+@import './defaults.css';
+@import './layouts/index.css';
+@import './utilities/index.css';
diff --git a/server/diypedals/web/styles/utilities/background.css b/server/diypedals/web/styles/utilities/background.css
new file mode 100644
index 00000000..6c6f2e1c
--- /dev/null
+++ b/server/diypedals/web/styles/utilities/background.css
@@ -0,0 +1,30 @@
+.f-background-default {
+ background-color: var(--background-default);
+}
+
+.f-background-alt-grey {
+ background-color: var(--background-alt-grey);
+}
+
+.f-background-alt-yellow {
+ background-color: var(--background-alt-yellow);
+}
+
+.f-background-wavy-yellow {
+ position: relative;
+}
+
+.f-background-wavy-yellow::before {
+ --color1: var(--background-alt-yellow);
+ --color2: var(--background-alt-yellow-darker);
+ content: '';
+ position: absolute;
+ z-index: -1;
+ width: 100%;
+ height: 100%;
+ background-color: #fff0c9;
+ background-image: linear-gradient(135deg, var(--color2) 25%, transparent 25%), linear-gradient(225deg, var(--color2) 25%, transparent 25%), linear-gradient(45deg, var(--color2) 25%, transparent 25%), linear-gradient(315deg, var(--color2) 25%, var(--color1) 25%);
+ background-position: 20px 0, 20px 0, 0 0, 0 0;
+ background-size: 60px 60px;
+ background-repeat: repeat;
+}
\ No newline at end of file
diff --git a/server/diypedals/web/styles/utilities/index.css b/server/diypedals/web/styles/utilities/index.css
new file mode 100644
index 00000000..164770dd
--- /dev/null
+++ b/server/diypedals/web/styles/utilities/index.css
@@ -0,0 +1,3 @@
+@import './background.css';
+@import './shadow.css';
+@import './sizing.css';
diff --git a/server/diypedals/web/styles/utilities/shadow.css b/server/diypedals/web/styles/utilities/shadow.css
new file mode 100644
index 00000000..c03c8125
--- /dev/null
+++ b/server/diypedals/web/styles/utilities/shadow.css
@@ -0,0 +1,3 @@
+.f-shadow {
+ box-shadow: var(--shadow-default);
+}
\ No newline at end of file
diff --git a/server/diypedals/web/styles/utilities/sizing.css b/server/diypedals/web/styles/utilities/sizing.css
new file mode 100644
index 00000000..9159fc98
--- /dev/null
+++ b/server/diypedals/web/styles/utilities/sizing.css
@@ -0,0 +1,3 @@
+.f-w-full {
+ width: 100%;
+}
\ No newline at end of file
diff --git a/server/diypedals/web/styles/variables.css b/server/diypedals/web/styles/variables.css
new file mode 100644
index 00000000..a4c63499
--- /dev/null
+++ b/server/diypedals/web/styles/variables.css
@@ -0,0 +1,44 @@
+:root {
+ --v: 4px;
+ --w: 8px;
+ --1w: 8px;
+ --2w: 16px;
+ --4w: 32px;
+
+ /* Colors */
+ --surface: #edeff0;
+ --muted: #6b6b6b;
+ --danger: #ff0000;
+ --primary: #1866ac;
+ --background-default: white;
+ --background-alt-grey: #eee;
+ --background-alt-yellow: #fff0c9;
+ --background-alt-yellow-darker: #f5e6bf;
+ --text-on-background-default: #424242;
+
+ /* Fonts */
+ --font-family: sans-serif;
+ --font-heading: var(--font-family);
+ --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;
+
+ /* Shadows */
+ --shadow-default: 0px 0px 20px 0px #49495a26;
+
+ /* 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..f9c58812
--- /dev/null
+++ b/server/diypedals/web/templates/base.jinja
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+ {% block title %}diypedals.fm{% endblock title %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {# {% if settings.WEBMENTIONS_URL %}
+
+
+ {% endif %} #}
+
+ {% block head %}{% endblock head %}
+
+
+
+ {% block body %}{% endblock %}
+
+
+
+ {% if settings.DEBUG %}
+ {{ hotreload.script(url_for('diypedals:hot-reload')) | safe }}
+ {% endif %}
+
+
+
+
+ {% block tail %}{% endblock %}
+
+
diff --git a/server/diypedals/web/templates/layouts/default.jinja b/server/diypedals/web/templates/layouts/default.jinja
new file mode 100644
index 00000000..b9e2336b
--- /dev/null
+++ b/server/diypedals/web/templates/layouts/default.jinja
@@ -0,0 +1,26 @@
+{% extends 'base.jinja'%}
+
+{% set _body_class = "f-background-wavy-yellow" %}
+
+{% block body %}
+
+
+
+
+ {% block main %}{% endblock %}
+
+
+{% endblock body %}
\ No newline at end of file
diff --git a/server/diypedals/web/templates/views/build_reports/_category_list.jinja b/server/diypedals/web/templates/views/build_reports/_category_list.jinja
new file mode 100644
index 00000000..1ee50a3d
--- /dev/null
+++ b/server/diypedals/web/templates/views/build_reports/_category_list.jinja
@@ -0,0 +1,15 @@
+{% macro CategoryList(build_report) %}
+
+ 🔖
+
+
+{% endmacro %}
diff --git a/server/diypedals/web/templates/views/build_reports/_category_sidebar.jinja b/server/diypedals/web/templates/views/build_reports/_category_sidebar.jinja
new file mode 100644
index 00000000..33540ec9
--- /dev/null
+++ b/server/diypedals/web/templates/views/build_reports/_category_sidebar.jinja
@@ -0,0 +1,14 @@
+{% macro CategorySidebar() %}
+
+{% endmacro %}
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..1c2a64ec
--- /dev/null
+++ b/server/diypedals/web/templates/views/build_reports/_detail_photos.jinja
@@ -0,0 +1,7 @@
+
+ {% for photo in build_report.photos %}
+ -
+
+
+ {% endfor %}
+
diff --git a/server/diypedals/web/templates/views/build_reports/_list_view.jinja b/server/diypedals/web/templates/views/build_reports/_list_view.jinja
new file mode 100644
index 00000000..a3aa3ae9
--- /dev/null
+++ b/server/diypedals/web/templates/views/build_reports/_list_view.jinja
@@ -0,0 +1,29 @@
+{% from 'views/build_reports/_category_list.jinja' import CategoryList with context %}
+
+{% macro ListView(build_reports) %}
+
+ {% for build_report in build_reports %}
+ -
+
+
+
+
+
+ {{ build_report.title }}
+
+
+
+ {{ build_report.description }}
+
+
+ {{ CategoryList(build_report) }}
+
+
+ 📅
+
+
+
+
+ {% endfor %}
+
+{% endmacro %}
\ No newline at end of file
diff --git a/server/diypedals/web/templates/views/build_reports/categories/detail.jinja b/server/diypedals/web/templates/views/build_reports/categories/detail.jinja
new file mode 100644
index 00000000..622e60c0
--- /dev/null
+++ b/server/diypedals/web/templates/views/build_reports/categories/detail.jinja
@@ -0,0 +1,23 @@
+{% extends 'layouts/default.jinja' %}
+{% from 'views/build_reports/_list_view.jinja' import ListView with context %}
+{% from 'views/build_reports/_category_sidebar.jinja' import CategorySidebar with context %}
+
+{% set title = category|capitalize + ' pedals' %}
+
+{% block title %}
+ {{ title }} - Categories - Build reports - {{ super() }}
+{% endblock %}
+
+{% block main %}
+ {{ title }}
+
+
+{% endblock %}
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..5e7129e3
--- /dev/null
+++ b/server/diypedals/web/templates/views/build_reports/detail.jinja
@@ -0,0 +1,56 @@
+{% extends 'layouts/default.jinja' %}
+{% from 'views/build_reports/_category_list.jinja' import CategoryList with context %}
+
+{% block title %}
+ {{ build_report.title }} - Build reports - {{ super() }}
+{% endblock %}
+
+{% block main %}
+ {{ build_report.title }}
+
+ {{ CategoryList(build_report) }}
+
+ Overview
+
+ Description: {{ build_report.description }}
+
+ 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 show_photos %}
+
+
+
+ {% for photo in build_report.photos %}
+ -
+
+
+ {% endfor %}
+
+ {% else %}
+
+
+ {% set photo = build_report.photos|first %}
+
+
+
+
+
+ {% endif %}
+
+{% endblock %}
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..479707cc
--- /dev/null
+++ b/server/diypedals/web/templates/views/build_reports/list.jinja
@@ -0,0 +1,40 @@
+{% extends 'layouts/default.jinja' %}
+{% from 'views/build_reports/_category_sidebar.jinja' import CategorySidebar with context %}
+{% from 'views/build_reports/_list_view.jinja' import ListView with context %}
+
+{% block title %}
+ Build reports - {{ super() }}
+{% endblock %}
+
+{% block main %}
+ Build reports
+
+
+
+ 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 %}
\ 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..6679c464
--- /dev/null
+++ b/server/diypedals/web/templates/views/index.jinja
@@ -0,0 +1,5 @@
+{% extends 'layouts/default.jinja' %}
+
+{% block main %}
+ diypedals.fm
+{% endblock main %}
diff --git a/server/diypedals/web/templating.py b/server/diypedals/web/templating.py
new file mode 100644
index 00000000..b9f9c7f9
--- /dev/null
+++ b/server/diypedals/web/templating.py
@@ -0,0 +1,33 @@
+import datetime as dt
+from typing import Any
+
+import jinja2
+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: Any) -> str:
+ 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..945b3503
--- /dev/null
+++ b/server/diypedals/web/views.py
@@ -0,0 +1,74 @@
+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()
+ categories = sorted(await build_report_repository.get_unique_categories())
+
+ context = {
+ "build_reports": build_reports,
+ "categories": categories,
+ }
+
+ 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: dict = {
+ "build_report": build_report,
+ "show_photos": request.query_params.get("photos") == "true",
+ }
+
+ return templates.TemplateResponse(
+ request, "views/build_reports/detail.jinja", context
+ )
+
+
+class BuildReportCategoryDetail(HTTPEndpoint):
+ async def get(self, request: Request) -> Response:
+ templates = resolve(Templates)
+ build_report_repository = resolve(BuildReportRepository)
+
+ category = request.path_params["category"]
+
+ build_reports = await build_report_repository.find_all(category=category)
+ categories = sorted(await build_report_repository.get_unique_categories())
+
+ context = {
+ "category": category,
+ "build_reports": build_reports,
+ "categories": categories,
+ "current_category": category,
+ }
+
+ return templates.TemplateResponse(
+ request, "views/build_reports/categories/detail.jinja", context
+ )
diff --git a/server/main.py b/server/main.py
index d057555d..dd724af6 100644
--- a/server/main.py
+++ b/server/main.py
@@ -1,21 +1,34 @@
+import copy
+
import uvicorn
import uvicorn.supervisors
+from uvicorn.config import LOGGING_CONFIG
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()
def get_config() -> uvicorn.Config:
+ log_config = copy.deepcopy(LOGGING_CONFIG)
+
+ log_config["loggers"]["httpx"] = log_config["loggers"]["httpcore"] = {
+ "handlers": ["default"],
+ "level": "INFO",
+ }
+
config: dict = dict(
host=settings.HOST,
port=settings.PORT,
use_colors=True,
reload=settings.DEBUG,
+ log_config=log_config,
)
return uvicorn.Config("server.main:app", **config)
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"),
diff --git a/tools/pybuild.py b/tools/pybuild.py
index 75057042..4748d7f6 100755
--- a/tools/pybuild.py
+++ b/tools/pybuild.py
@@ -49,7 +49,7 @@ def _replace_imports(path: Path) -> str:
lines = path.read_text().splitlines(keepends=True)
- for lineno, line, full_path in imports:
+ for lineno, _, full_path in imports:
content = _replace_imports(full_path)
lines[lineno] = content
@@ -77,12 +77,14 @@ def main(input_files: list[Path], outdir: Path, watch: bool = False) -> None:
if watch:
watched_files = set(input_files)
+ files_to_analyze = set(input_files)
- for path in input_files:
+ while files_to_analyze:
+ path = files_to_analyze.pop()
for _, _, full_path in _get_imports(path):
watched_files.add(full_path)
+ files_to_analyze.add(full_path)
- print("Watching for changes...")
for changes in watchfiles.watch(*watched_files, raise_interrupt=False):
changed_files = [changed_path for _, changed_path in changes]
print(f"--> Change detected in {', '.join(changed_files)}")