From 72a4741cacd1769cbb0d2687e2871ca5a4388472 Mon Sep 17 00:00:00 2001 From: Kolkman Date: Wed, 26 Aug 2026 12:54:38 +0200 Subject: [PATCH] Added a theme selector --- README.md | 2 + VIBE/CHAT_LOG.md | 18 +++++ VIBE/PROMPTS.md | 3 + backend/app/api/admin.py | 19 +++++ backend/app/api/public.py | 7 ++ backend/app/services/email_service.py | 6 +- backend/app/services/theme_service.py | 46 +++++++++++ backend/tests/test_api.py | 29 ++++++- frontend/static/admin.js | 35 ++++++++- frontend/static/style.css | 102 ++++++++++++++++++++++++- frontend/static/theme.js | 28 +++++++ frontend/templates/about.html | 1 + frontend/templates/admin.html | 12 ++- frontend/templates/feed.html | 1 + frontend/templates/labels.html | 1 + frontend/templates/login.html | 1 + frontend/templates/reset-password.html | 1 + frontend/templates/setup.html | 1 + frontend/templates/user_profile.html | 1 + 19 files changed, 305 insertions(+), 9 deletions(-) create mode 100644 backend/app/services/theme_service.py create mode 100644 frontend/static/theme.js diff --git a/README.md b/README.md index 198baf1..a55896c 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,8 @@ The inline link editor also allows multiple existing tags to be selected and new The installation seeds these available tags: `#Internet`, `#Cybersecurity`, `#Fediverse`, `#Food`, `#Photography`, `#Music`, and `#AI`. Users can create, edit, and delete their own labels from `/labels`. Administrators can delete any label from `/admin`; system-seeded labels have no user owner. +Administrators can enable one or more backend themes from `/admin`: Plain Day, Plain Night, Catppuccin Latte, Catppuccin Frappe, Catppuccin Macchiato, Catppuccin Mocha, Dracula, Nord, and Solarized. Visitors can choose among enabled themes; their choice is stored locally in the browser. At least one theme must remain enabled. + Admin plugin requests must include the administrator's token: ```sh diff --git a/VIBE/CHAT_LOG.md b/VIBE/CHAT_LOG.md index ff613d1..baa8bbe 100644 --- a/VIBE/CHAT_LOG.md +++ b/VIBE/CHAT_LOG.md @@ -266,6 +266,24 @@ Report any errors that may occur from the SMTP module to the user. ### Assistant outcome Updated admin SMTP, user-creation, and validation error handling to display backend SMTP error details and network failures in the UI. Added regression coverage confirming SMTP delivery errors are returned as actionable `503` details. +### User +In the admin screen allow selecting multiple themes for the backend. Create at least one plain day and one night theme, and add all Catppuccin themes. + +### Assistant outcome +Added persisted multi-theme administration with Plain Day, Plain Night, Catppuccin Latte, Frappe, Macchiato, and Mocha themes. Administrators can enable any non-empty combination, while visitors can select from the enabled themes using a local preference. + +### User +For the day and Latte themes, the contrast on the link items is too low. + +### Assistant outcome +Repaired the light-theme contrast by using theme surfaces for link items and toolbars instead of a fixed dark overlay, and strengthened day/Latte text and accent colors for readable titles, metadata, and secondary content. + +### User +Add three other popular themes. + +### Assistant outcome +Added Dracula, Nord, and Solarized themes to the administrator’s multi-theme selector and the visitor theme picker, with matching backend registry entries, CSS palettes, test coverage, and documentation. + ### User Show the avatar with each entry on the home page. On the specific // page don't show the avatar and user name with each entry diff --git a/VIBE/PROMPTS.md b/VIBE/PROMPTS.md index ceb3080..571cb69 100644 --- a/VIBE/PROMPTS.md +++ b/VIBE/PROMPTS.md @@ -130,6 +130,9 @@ 121. On the admin page add the SMTP settings (with the validation button). 122. Populate the SMTP fields with the current values except for the password. Send the testmail to the currently authenticated admin user. Only use updated fields when testing. Use the same logic as in the configuration page to restrict endless testing. 123. Report any errors that may occur from the SMTP module to the user. +124. In the admin screen allow to select multiple themes for the backend. Create at least one plain day and one night theme, and add in all catpuccin themes for good measure. +125. For the day and latte themes the contrast on the link items is too low. +126. Add 3 other of the most popular themes. ## Future entries diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 9a6183d..9ab62d1 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -19,6 +19,7 @@ from backend.app.services.email_service import ( smtp_configured, ) from backend.app.services.email_verification import create_verification_token +from backend.app.services.theme_service import THEMES, get_enabled_themes, save_enabled_themes from backend.app.core.config import settings router = APIRouter() @@ -49,6 +50,10 @@ class AdminSmtpUpdate(BaseModel): smtp_use_tls: bool = True +class AdminThemesUpdate(BaseModel): + themes: list[str] + + def validate_smtp_values(payload: AdminSmtpUpdate, current: dict | None = None) -> dict: smtp_host = payload.smtp_host.strip() smtp_from = payload.smtp_from.strip() @@ -139,6 +144,20 @@ def get_admin_smtp_settings(_: dict = Depends(require_admin)): return public_smtp_settings(get_smtp_settings()) +@router.get('/themes') +def get_admin_themes(_: dict = Depends(require_admin)): + return {'themes': THEMES, 'enabled': get_enabled_themes()} + + +@router.put('/themes') +def update_admin_themes(payload: AdminThemesUpdate, _: dict = Depends(require_admin)): + try: + enabled = save_enabled_themes(payload.themes) + except ValueError as error: + raise HTTPException(status_code=422, detail=str(error)) from error + return {'themes': THEMES, 'enabled': enabled} + + @router.put('/smtp') def update_admin_smtp_settings(payload: AdminSmtpUpdate, _: dict = Depends(require_admin)): current = get_smtp_settings() diff --git a/backend/app/api/public.py b/backend/app/api/public.py index f2a56cf..3c0ce67 100644 --- a/backend/app/api/public.py +++ b/backend/app/api/public.py @@ -6,6 +6,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from backend.app.services.link_service import list_public_links, list_public_users from backend.app.services.token_service import validate_token +from backend.app.services.theme_service import THEMES, get_enabled_themes router = APIRouter() optional_bearer = HTTPBearer(auto_error=False) @@ -16,6 +17,12 @@ def public_users(): return list_public_users() +@router.get('/themes') +def public_themes(): + enabled = get_enabled_themes() + return [{'id': theme, **THEMES[theme]} for theme in enabled] + + @router.get('/feed') @router.get('/feed/{username}') def public_feed( diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 468d663..56ec763 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -35,14 +35,14 @@ def save_smtp_settings(values: dict) -> None: conn.commit() -def smtp_configured() -> bool: - smtp = get_smtp_settings() +def smtp_configured(smtp_values: dict | None = None) -> bool: + smtp = smtp_values or get_smtp_settings() return bool(smtp['smtp_host'] and smtp['smtp_from']) def send_message(email: str, subject: str, body: str, smtp_values: dict | None = None) -> None: smtp = smtp_values or get_smtp_settings() - if not smtp_configured(): + if not smtp_configured(smtp): raise RuntimeError('SMTP is not configured; set LINKLOG_SMTP_HOST and LINKLOG_SMTP_FROM') message = EmailMessage() diff --git a/backend/app/services/theme_service.py b/backend/app/services/theme_service.py new file mode 100644 index 0000000..f2cf9f2 --- /dev/null +++ b/backend/app/services/theme_service.py @@ -0,0 +1,46 @@ +## Copyright © 2026 Olaf Kolkman +## SPDX-License-Identifier: GPL-3.0-or-later + +import json + +from backend.app.database import get_connection + + +THEMES = { + 'plain-day': {'label': 'Plain Day', 'description': 'A bright, neutral daytime theme.'}, + 'plain-night': {'label': 'Plain Night', 'description': 'A neutral dark nighttime theme.'}, + 'latte': {'label': 'Catppuccin Latte', 'description': 'Catppuccin light theme.'}, + 'frappe': {'label': 'Catppuccin Frappe', 'description': 'Catppuccin soft dark theme.'}, + 'macchiato': {'label': 'Catppuccin Macchiato', 'description': 'Catppuccin medium dark theme.'}, + 'mocha': {'label': 'Catppuccin Mocha', 'description': 'Catppuccin deep dark theme.'}, + 'dracula': {'label': 'Dracula', 'description': 'A vivid dark theme with high-contrast accents.'}, + 'nord': {'label': 'Nord', 'description': 'A cool, muted blue-gray theme.'}, + 'solarized': {'label': 'Solarized', 'description': 'A balanced theme available in a light style.'}, +} +DEFAULT_ENABLED_THEMES = tuple(THEMES) + + +def get_enabled_themes() -> list[str]: + with get_connection() as conn: + row = conn.execute('SELECT value FROM app_settings WHERE name = ?', ('enabled_themes',)).fetchone() + if row is None: + return list(DEFAULT_ENABLED_THEMES) + try: + configured = json.loads(row['value']) + except (TypeError, json.JSONDecodeError): + return list(DEFAULT_ENABLED_THEMES) + return [theme for theme in configured if theme in THEMES] or ['mocha'] + + +def save_enabled_themes(themes: list[str]) -> list[str]: + selected = list(dict.fromkeys(theme for theme in themes if theme in THEMES)) + if not selected: + raise ValueError('At least one theme must be enabled') + with get_connection() as conn: + conn.execute( + '''INSERT INTO app_settings (name, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(name) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP''', + ('enabled_themes', json.dumps(selected)), + ) + conn.commit() + return selected diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 358e84d..b44cf50 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -62,9 +62,25 @@ def test_configuration_requires_authentication_and_admin_role(): assert client.get('/api/admin/smtp', headers=login_headers('bob')).status_code == 403 +def test_admin_can_select_multiple_themes(): + headers = login_headers() + response = client.put('/api/admin/themes', headers=headers, json={ + 'themes': ['plain-day', 'plain-night', 'latte', 'frappe', 'macchiato', 'mocha', 'dracula', 'nord', 'solarized'], + }) + assert response.status_code == 200 + assert response.json()['enabled'] == ['plain-day', 'plain-night', 'latte', 'frappe', 'macchiato', 'mocha', 'dracula', 'nord', 'solarized'] + public_response = client.get('/api/public/themes') + assert public_response.status_code == 200 + assert [theme['id'] for theme in public_response.json()] == response.json()['enabled'] + + assert client.put('/api/admin/themes', headers=headers, json={'themes': []}).status_code == 422 + + def test_admin_can_save_and_validate_smtp_settings(): headers = login_headers() original = get_smtp_settings() + with get_connection() as conn: + original_row = conn.execute('SELECT value FROM app_settings WHERE name = ?', ('smtp',)).fetchone() response = client.put('/api/admin/smtp', headers=headers, json={ 'smtp_host': 'smtp.example.com', 'smtp_port': 587, @@ -97,7 +113,18 @@ def test_admin_can_save_and_validate_smtp_settings(): 'smtp_use_tls': False, }) - client.put('/api/admin/smtp', headers=headers, json=original) + with get_connection() as conn: + if original_row is None: + conn.execute('DELETE FROM app_settings WHERE name = ?', ('smtp',)) + else: + conn.execute( + 'UPDATE app_settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?', + (original_row['value'], 'smtp'), + ) + conn.commit() + with get_connection() as conn: + conn.execute('DELETE FROM app_settings WHERE name = ?', ('admin_smtp_mail_rate',)) + conn.commit() def test_admin_reports_smtp_validation_errors(): diff --git a/frontend/static/admin.js b/frontend/static/admin.js index 6237c40..6520caf 100644 --- a/frontend/static/admin.js +++ b/frontend/static/admin.js @@ -11,6 +11,9 @@ const adminAuthNotice = document.querySelector('#admin-auth-notice'); const smtpForm = document.querySelector('#smtp-form'); const smtpTestButton = document.querySelector('#smtp-test-button'); const smtpStatus = document.querySelector('#smtp-status'); +const themesForm = document.querySelector('#themes-form'); +const themeOptions = document.querySelector('#theme-options'); +const themeStatus = document.querySelector('#theme-status'); let smtpNextAllowedAt = null; let smtpTimerHandle = null; const accessToken = localStorage.getItem('linklogAccessToken'); @@ -113,6 +116,22 @@ async function loadSmtpSettings() { } } +async function loadThemes() { + const response = await fetch('/api/admin/themes', {headers: authHeaders()}); + if (!response.ok) throw new Error('Could not load themes'); + const result = await response.json(); + themeOptions.replaceChildren(...Object.entries(result.themes).map(([id, theme]) => { + const label = document.createElement('label'); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.name = 'theme'; + checkbox.value = id; + checkbox.checked = result.enabled.includes(id); + label.append(checkbox, document.createTextNode(` ${theme.label}`)); + return label; + })); +} + function renderUsers(users) { userList.replaceChildren(...users.map((user) => { const row = document.createElement('div'); @@ -185,7 +204,7 @@ async function loadAdminState() { return; } - await Promise.all([loadUsers(), loadPlugins(), loadLabels(), loadSmtpSettings()]); + await Promise.all([loadUsers(), loadPlugins(), loadLabels(), loadSmtpSettings(), loadThemes()]); showAdminState(true); } @@ -312,6 +331,19 @@ smtpTestButton.addEventListener('click', async () => { updateSmtpTimer(); }); +themesForm.addEventListener('submit', async (event) => { + event.preventDefault(); + const themes = [...themesForm.querySelectorAll('input[name="theme"]:checked')].map((input) => input.value); + const response = await fetch('/api/admin/themes', { + method: 'PUT', + headers: authHeaders(true), + body: JSON.stringify({themes}), + }); + const result = await response.json(); + themeStatus.textContent = response.ok ? 'Themes saved.' : (result.detail || 'Could not save themes.'); + themeStatus.style.color = response.ok ? '#94e2d5' : '#f38ba8'; +}); + loadAdminState().catch((error) => { showAdminState(false); adminAuthNotice.textContent = accessToken @@ -322,5 +354,6 @@ loadAdminState().catch((error) => { pluginList.textContent = ''; adminLabelList.textContent = ''; smtpForm.reset(); + themesForm.reset(); }); })(); diff --git a/frontend/static/style.css b/frontend/static/style.css index 52ddfe6..2829229 100644 --- a/frontend/static/style.css +++ b/frontend/static/style.css @@ -23,6 +23,78 @@ --shadow: 0 18px 50px rgba(17, 17, 27, 0.28); } +:root[data-theme='plain-day'] { + --base: #f4f1ea; --mantle: #e8e3d8; --crust: #d7d0c2; + --surface-0: #fffdf8; --surface-1: #e5ded1; --surface-2: #c8beae; + --text: #2f2b27; --subtext: #514a42; --muted: #6b6258; + --mauve: #7a3d6e; --lavender: #5d4b84; --blue: #315c78; + --teal: #397c72; --peach: #a15d3d; --red: #a44250; + --border: rgba(47, 43, 39, 0.16); --shadow: 0 18px 50px rgba(47, 43, 39, 0.16); +} + +:root[data-theme='plain-night'] { + --base: #181818; --mantle: #111111; --crust: #090909; + --surface-0: #262626; --surface-1: #353535; --surface-2: #4b4b4b; + --text: #eeeeee; --subtext: #c1c1c1; --muted: #929292; + --mauve: #d59acb; --lavender: #b9b4e8; --blue: #8ebbd8; + --teal: #8bc9bd; --peach: #e2ae88; --red: #ec929f; + --border: rgba(238, 238, 238, 0.14); --shadow: 0 18px 50px rgba(0, 0, 0, 0.35); +} + +:root[data-theme='latte'] { + --base: #eff1f5; --mantle: #e6e9ef; --crust: #dce0e8; + --surface-0: #ccd0da; --surface-1: #bcc0cc; --surface-2: #acb0be; + --text: #3d4058; --subtext: #51546b; --muted: #65687c; + --mauve: #7627c7; --lavender: #5946b2; --blue: #1854c7; + --teal: #179299; --peach: #fe640b; --red: #d20f39; + --border: rgba(76, 79, 105, 0.16); --shadow: 0 18px 50px rgba(76, 79, 105, 0.16); +} + +:root[data-theme='frappe'] { + --base: #303446; --mantle: #292c3c; --crust: #232634; + --surface-0: #414559; --surface-1: #51576d; --surface-2: #626880; + --text: #c6d0f5; --subtext: #b5bfe2; --muted: #838ba7; + --mauve: #ca9ee6; --lavender: #babbf1; --blue: #8caaee; + --teal: #81c8be; --peach: #ef9f76; --red: #e78284; + --border: rgba(198, 208, 245, 0.12); --shadow: 0 18px 50px rgba(35, 38, 52, 0.3); +} + +:root[data-theme='macchiato'] { + --base: #24273a; --mantle: #1e2030; --crust: #181926; + --surface-0: #363a4f; --surface-1: #494d64; --surface-2: #5b6078; + --text: #cad3f5; --subtext: #b8c0e0; --muted: #8087a2; + --mauve: #c6a0f6; --lavender: #b7bdf8; --blue: #8aadf4; + --teal: #8bd5ca; --peach: #f5a97f; --red: #ed8796; + --border: rgba(202, 211, 245, 0.12); --shadow: 0 18px 50px rgba(24, 25, 38, 0.34); +} + +:root[data-theme='dracula'] { + --base: #282a36; --mantle: #21222c; --crust: #191a21; + --surface-0: #44475a; --surface-1: #6272a4; --surface-2: #7886b5; + --text: #f8f8f2; --subtext: #d6d6d0; --muted: #a7a7a0; + --mauve: #ff79c6; --lavender: #bd93f9; --blue: #8be9fd; + --teal: #50fa7b; --peach: #ffb86c; --red: #ff5555; + --border: rgba(248, 248, 242, 0.14); --shadow: 0 18px 50px rgba(25, 26, 33, 0.35); +} + +:root[data-theme='nord'] { + --base: #2e3440; --mantle: #272c36; --crust: #242933; + --surface-0: #3b4252; --surface-1: #434c5e; --surface-2: #4c566a; + --text: #eceff4; --subtext: #d8dee9; --muted: #aeb8c8; + --mauve: #b48ead; --lavender: #d8dee9; --blue: #88c0d0; + --teal: #a3be8c; --peach: #d08770; --red: #bf616a; + --border: rgba(236, 239, 244, 0.14); --shadow: 0 18px 50px rgba(36, 41, 51, 0.35); +} + +:root[data-theme='solarized'] { + --base: #fdf6e3; --mantle: #eee8d5; --crust: #e3ddc9; + --surface-0: #eee8d5; --surface-1: #ddd6c1; --surface-2: #c9c1aa; + --text: #073642; --subtext: #586e75; --muted: #657b83; + --mauve: #6c71c4; --lavender: #268bd2; --blue: #268bd2; + --teal: #2aa198; --peach: #cb4b16; --red: #dc322f; + --border: rgba(7, 54, 66, 0.18); --shadow: 0 18px 50px rgba(7, 54, 66, 0.14); +} + *, *::before, *::after { @@ -91,7 +163,7 @@ body::selection { .site-logo { display: block; width: min(260px, 70vw); - height: 150px; + height: 80px; margin-bottom: 12px; object-fit: contain; object-position: left center; @@ -124,6 +196,17 @@ body::selection { position: relative; } +.theme-picker { + width: auto; + min-width: 132px; + padding: 8px 10px; + border: 1px solid var(--surface-2); + border-radius: 7px; + background: var(--surface-0); + color: var(--text); + font: inherit; +} + .menu-toggle { min-width: 0; padding: 10px 13px; @@ -237,7 +320,7 @@ main.container { gap: 14px; margin: 0 0 24px; padding: 16px; - background: rgba(24, 24, 37, 0.72); + background: var(--surface-0); border: 1px solid var(--border); border-radius: 12px; } @@ -302,6 +385,19 @@ button:focus-visible { max-width: 560px; } +.theme-options { + display: grid; + gap: 8px; + margin: 14px 0; +} + +.theme-options label { + display: flex; + align-items: center; + gap: 8px; + margin: 0; +} + .settings-panel textarea { min-height: 110px; resize: vertical; @@ -522,7 +618,7 @@ button:disabled { .link-item { padding: 11px; - background: rgba(49, 50, 68, 0.84); + background: var(--surface-0); border: 1px solid var(--border); border-radius: 12px; box-shadow: var(--shadow); diff --git a/frontend/static/theme.js b/frontend/static/theme.js new file mode 100644 index 0000000..d5bc5bd --- /dev/null +++ b/frontend/static/theme.js @@ -0,0 +1,28 @@ +// Copyright © 2026 Olaf Kolkman +// SPDX-License-Identifier: GPL-3.0-or-later + +const themePreference = 'linklog-theme'; + +async function loadAvailableThemes() { + const response = await fetch('/api/public/themes'); + if (!response.ok) return; + const themes = await response.json(); + const selected = themes.some((theme) => theme.id === localStorage.getItem(themePreference)) + ? localStorage.getItem(themePreference) + : themes[0]?.id; + if (selected) document.documentElement.dataset.theme = selected; + const headerActions = document.querySelector('.header-actions'); + if (!headerActions || !themes.length) return; + const picker = document.createElement('select'); + picker.className = 'theme-picker'; + picker.setAttribute('aria-label', 'Theme'); + picker.replaceChildren(...themes.map((theme) => new Option(theme.label, theme.id))); + picker.value = selected || themes[0].id; + picker.addEventListener('change', () => { + localStorage.setItem(themePreference, picker.value); + document.documentElement.dataset.theme = picker.value; + }); + headerActions.prepend(picker); +} + +loadAvailableThemes(); \ No newline at end of file diff --git a/frontend/templates/about.html b/frontend/templates/about.html index 4548f11..d7a4d59 100644 --- a/frontend/templates/about.html +++ b/frontend/templates/about.html @@ -56,5 +56,6 @@ + diff --git a/frontend/templates/admin.html b/frontend/templates/admin.html index 5e4ea2b..31924b6 100644 --- a/frontend/templates/admin.html +++ b/frontend/templates/admin.html @@ -98,17 +98,27 @@ +

- + + diff --git a/frontend/templates/feed.html b/frontend/templates/feed.html index 47f09c5..42beb87 100644 --- a/frontend/templates/feed.html +++ b/frontend/templates/feed.html @@ -79,6 +79,7 @@ + diff --git a/frontend/templates/labels.html b/frontend/templates/labels.html index 944e138..e85c824 100644 --- a/frontend/templates/labels.html +++ b/frontend/templates/labels.html @@ -47,6 +47,7 @@ + diff --git a/frontend/templates/login.html b/frontend/templates/login.html index d111ab8..ea4869a 100644 --- a/frontend/templates/login.html +++ b/frontend/templates/login.html @@ -54,6 +54,7 @@ + diff --git a/frontend/templates/reset-password.html b/frontend/templates/reset-password.html index c424971..a901291 100644 --- a/frontend/templates/reset-password.html +++ b/frontend/templates/reset-password.html @@ -19,6 +19,7 @@ + \ No newline at end of file diff --git a/frontend/templates/setup.html b/frontend/templates/setup.html index bfd7235..e06313f 100644 --- a/frontend/templates/setup.html +++ b/frontend/templates/setup.html @@ -43,6 +43,7 @@
Copyright © 2026 Olaf Kolkman
+ \ No newline at end of file diff --git a/frontend/templates/user_profile.html b/frontend/templates/user_profile.html index a7d3edf..39398dd 100644 --- a/frontend/templates/user_profile.html +++ b/frontend/templates/user_profile.html @@ -97,6 +97,7 @@ +