Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
MYLONITE_CONFIG_DIR=./runtime/config
MYLONITE_DATA_DIR=./runtime/data
MYLONITE_CONTENT_DIR=./content
MYLONITE_THEMES_DIR=./themes
MYLONITE_BIND_PORT=8000

# Linux-friendly defaults for bind-mounted file ownership.
Expand Down
2 changes: 1 addition & 1 deletion .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
/mylonite/ @remdui
/templates/ @remdui
/content/ @remdui
/static/ @remdui
/themes/ @remdui
/tests/ @remdui

# Infrastructure and automation
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ Optional overrides:

- If you want to customize bind paths, port, or runtime UID/GID mapping, copy `.env.example` to `.env` and adjust values.
- On Linux, leave `MYLONITE_PUID/MYLONITE_PGID` at `1000` unless your user/group IDs are different.
- Theme folders are bind-mounted from `./themes` by default (`MYLONITE_THEMES_DIR`).

#### Initialize editable local content

Expand Down Expand Up @@ -210,7 +211,7 @@ Mylonite/
runtime/config/ Local runtime configs
runtime/data/ Persistent runtime states
scripts/ Helper scripts
static/ Static source files
themes/ Theme folders and static theme assets
templates/ Django website templates
```

Expand All @@ -225,4 +226,3 @@ Mylonite/
## License

Licensed under the GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later). See the `LICENSE` file for details.

17 changes: 17 additions & 0 deletions apps/panel/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,3 +165,20 @@ def __init__(self, user, *args, **kwargs) -> None:
"placeholder": "Repeat new password",
}
)


class ThemeSelectionForm(StyledFormMixin, forms.Form):
theme_name = forms.ChoiceField(
label="Theme",
choices=(),
)

def __init__(
self,
*args,
theme_choices: list[tuple[str, str]] | None = None,
**kwargs,
) -> None:
super().__init__(*args, **kwargs)
self.fields["theme_name"].choices = theme_choices or []
self._apply_common_widget_attrs()
166 changes: 154 additions & 12 deletions apps/panel/views.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from django.contrib import messages
from django.contrib.auth import login
from django.contrib.auth import update_session_auth_hash
from django.contrib.auth import views as auth_views
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.db import IntegrityError, transaction
from django.http import HttpRequest, HttpResponse
Expand All @@ -10,7 +12,20 @@
from django.views import View
from django.views.generic import FormView, TemplateView

from .forms import OwnerSetupForm, PanelAuthenticationForm, PanelPasswordChangeForm
from pathlib import Path

from mylonite.core.site_config_store import (
load_site_config_payload,
write_site_config_payload,
)
from mylonite.core.theme_loader import ThemeResolver, load_active_theme_settings

from .forms import (
OwnerSetupForm,
PanelAuthenticationForm,
PanelPasswordChangeForm,
ThemeSelectionForm,
)
from .models import SiteSetup
from .services import (
InitialSetupAlreadyComplete,
Expand Down Expand Up @@ -175,18 +190,145 @@ class PanelDashboardView(OwnerRequiredMixin, PanelContextMixin, TemplateView):
)


class PanelSettingsView(
OwnerRequiredMixin,
PanelContextMixin,
auth_views.PasswordChangeView,
):
class PanelSettingsView(OwnerRequiredMixin, PanelContextMixin, TemplateView):
template_name = "panel/settings.html"
form_class = PanelPasswordChangeForm
success_url = reverse_lazy("panel:settings")
panel_section = "settings"
panel_heading = "Settings"
panel_description = "Update your owner account credentials."
panel_description = "Update your owner account credentials and theme."

def form_valid(self, form):
messages.success(self.request, "Password updated successfully.")
return super().form_valid(form)
@property
def _content_root(self) -> Path:
return Path(settings.MYLONITE_CONTENT_ROOT)

@property
def _themes_root(self) -> Path:
return Path(settings.MYLONITE_THEMES_ROOT)

def _load_theme_context(self):
resolver = ThemeResolver(self._themes_root)
site_theme_settings = load_active_theme_settings(
content_root=self._content_root
)
discovered_themes = resolver.discover_themes()
resolved_theme = resolver.resolve(
site_theme_settings,
themes=discovered_themes,
)
selectable_themes = resolver.selectable_themes(
custom_theme_allowed=site_theme_settings.custom_theme_allowed,
themes=discovered_themes,
)

return {
"site_theme_settings": site_theme_settings,
"resolved_theme": resolved_theme,
"selectable_themes": selectable_themes,
"theme_choices": [
(
theme.theme_id,
f"{theme.metadata.name} ({theme.theme_id})",
)
for theme in selectable_themes
],
}

def _get_password_form(self, data=None):
return PanelPasswordChangeForm(self.request.user, data=data)

def _get_theme_form(self, *, theme_choices, data=None, initial_theme_id="default"):
return ThemeSelectionForm(
data=data,
theme_choices=theme_choices,
initial={"theme_name": initial_theme_id},
)

def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
theme_context = self._load_theme_context()

password_form = kwargs.get("password_form") or self._get_password_form()
theme_form = kwargs.get("theme_form") or self._get_theme_form(
theme_choices=theme_context["theme_choices"],
initial_theme_id=theme_context["resolved_theme"].active_theme.theme_id,
)

context["password_form"] = password_form
context["theme_form"] = theme_form
context["theme_options"] = theme_context["selectable_themes"]
context["active_theme_id"] = theme_context[
"resolved_theme"
].active_theme.theme_id
context["custom_theme_allowed"] = theme_context[
"site_theme_settings"
].custom_theme_allowed
context["missing_theme_files"] = theme_context[
"resolved_theme"
].missing_required_static_files
return context

def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
action = request.POST.get("settings_action", "").strip().lower()
if action == "password":
return self._handle_password_update()
if action == "theme":
return self._handle_theme_update()
return redirect("panel:settings")

def _handle_password_update(self) -> HttpResponse:
password_form = self._get_password_form(data=self.request.POST)
theme_context = self._load_theme_context()
theme_form = self._get_theme_form(
theme_choices=theme_context["theme_choices"],
initial_theme_id=theme_context["resolved_theme"].active_theme.theme_id,
)

if password_form.is_valid():
user = password_form.save()
update_session_auth_hash(self.request, user)
messages.success(self.request, "Password updated successfully.")
return redirect("panel:settings")

context = self.get_context_data(
password_form=password_form,
theme_form=theme_form,
)
return self.render_to_response(context)

def _handle_theme_update(self) -> HttpResponse:
theme_context = self._load_theme_context()
password_form = self._get_password_form()
theme_form = self._get_theme_form(
data=self.request.POST,
theme_choices=theme_context["theme_choices"],
initial_theme_id=theme_context["resolved_theme"].active_theme.theme_id,
)

if theme_form.is_valid():
selected_theme_id = theme_form.cleaned_data["theme_name"]
payload = load_site_config_payload(self._content_root)
theme_payload = payload.setdefault("theme", {})
theme_payload["name"] = selected_theme_id
write_site_config_payload(self._content_root, payload)

selected_theme = next(
(
theme
for theme in theme_context["selectable_themes"]
if theme.theme_id == selected_theme_id
),
None,
)
display_name = (
selected_theme.metadata.name if selected_theme else selected_theme_id
)
messages.success(
self.request,
f"Theme updated to {display_name}.",
)
return redirect("panel:settings")

context = self.get_context_data(
password_form=password_form,
theme_form=theme_form,
)
return self.render_to_response(context)
10 changes: 10 additions & 0 deletions apps/web/context_processors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from django.urls import reverse


def theme_assets(_request):
return {
"theme_css_url": reverse(
"theme_static",
kwargs={"asset_path": "css/site.css"},
)
}
1 change: 1 addition & 0 deletions apps/web/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from . import views

urlpatterns = [
path("theme-static/<path:asset_path>", views.theme_static, name="theme_static"),
path("health/", views.health, name="health"),
path("", views.home, name="home"),
]
89 changes: 89 additions & 0 deletions apps/web/views.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
import mimetypes
from pathlib import Path

from django.conf import settings
from django.http import FileResponse
from django.http import Http404
from django.http import JsonResponse
from django.http import HttpResponse
from django.views.generic import TemplateView, View

from mylonite.core.theme_loader import (
ResolvedTheme,
ThemeResolver,
load_active_theme_settings,
normalize_theme_asset_path,
)

from .page_contexts import HomePageContextBuilder, WebPageContextFactory


Expand All @@ -26,5 +40,80 @@ class HomePageView(PageContextTemplateView):
page_name = HomePageContextBuilder.page_name


class ThemeStaticView(View):
primary_css_asset_path = "css/site.css"

def _build_css_response(
self,
*,
resolved_theme: ResolvedTheme,
normalized_path: str,
active_css_path: Path,
) -> HttpResponse | None:
if normalized_path != self.primary_css_asset_path:
return None

if (
resolved_theme.active_theme.theme_id
== resolved_theme.default_theme.theme_id
):
return None

default_css_path = resolved_theme.default_theme.static_dir / normalized_path
if not default_css_path.is_file():
return None

try:
default_css = default_css_path.read_text(encoding="utf-8")
active_css = active_css_path.read_text(encoding="utf-8")
except OSError:
return None

merged_css = (
f"{default_css}\n\n"
f"/* Theme overrides ({resolved_theme.active_theme.theme_id}) */\n"
f"{active_css}\n"
)
response = HttpResponse(merged_css, content_type="text/css; charset=utf-8")
response["Cache-Control"] = "no-cache"
return response

def get(self, request, asset_path: str):
normalized_path = normalize_theme_asset_path(asset_path)
if not normalized_path:
raise Http404("Invalid theme asset path.")

resolver = ThemeResolver(Path(settings.MYLONITE_THEMES_ROOT))
discovered_themes = resolver.discover_themes()
theme_settings = load_active_theme_settings(
content_root=Path(settings.MYLONITE_CONTENT_ROOT)
)
resolved_theme = resolver.resolve(theme_settings, themes=discovered_themes)
resolved_asset = resolver.resolve_static_asset(
resolved_theme,
asset_path=normalized_path,
)
if resolved_asset is None:
raise Http404("Theme asset not found.")

if normalized_path.endswith(".css") and not resolved_asset.from_fallback:
css_response = self._build_css_response(
resolved_theme=resolved_theme,
normalized_path=normalized_path,
active_css_path=resolved_asset.resolved_path,
)
if css_response is not None:
return css_response

content_type, _ = mimetypes.guess_type(resolved_asset.resolved_path.as_posix())
response = FileResponse(
resolved_asset.resolved_path.open("rb"),
content_type=content_type or "application/octet-stream",
)
response["Cache-Control"] = "no-cache"
return response


health = HealthView.as_view()
home = HomePageView.as_view()
theme_static = ThemeStaticView.as_view()
4 changes: 4 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ services:
MYLONITE_DATA_ROOT: /data
MYLONITE_DB_PATH: /data/db/mylonite.sqlite3
MYLONITE_CONTENT_ROOT: /content
MYLONITE_THEMES_ROOT: /themes
volumes:
- type: bind
source: ${MYLONITE_CONFIG_DIR:-./runtime/config}
Expand All @@ -19,6 +20,9 @@ services:
- type: bind
source: ${MYLONITE_CONTENT_DIR:-./content}
target: /content
- type: bind
source: ${MYLONITE_THEMES_DIR:-./themes}
target: /themes
user: "${MYLONITE_PUID:-1000}:${MYLONITE_PGID:-1000}"
restart: unless-stopped
init: true
Expand Down
Loading
Loading