Added a theme selector

This commit is contained in:
2026-08-26 12:54:38 +02:00
parent fec4c8def5
commit 72a4741cac
19 changed files with 305 additions and 9 deletions
+2
View File
@@ -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`. 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. 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: Admin plugin requests must include the administrator's token:
```sh ```sh
+18
View File
@@ -266,6 +266,24 @@ Report any errors that may occur from the SMTP module to the user.
### Assistant outcome ### 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. 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 administrators multi-theme selector and the visitor theme picker, with matching backend registry entries, CSS palettes, test coverage, and documentation.
### User ### User
Show the avatar with each entry on the home page. On the specific /<user>/ page don't show the avatar and user name with each entry Show the avatar with each entry on the home page. On the specific /<user>/ page don't show the avatar and user name with each entry
+3
View File
@@ -130,6 +130,9 @@
121. On the admin page add the SMTP settings (with the validation button). 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. 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. 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 ## Future entries
+19
View File
@@ -19,6 +19,7 @@ from backend.app.services.email_service import (
smtp_configured, smtp_configured,
) )
from backend.app.services.email_verification import create_verification_token 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 from backend.app.core.config import settings
router = APIRouter() router = APIRouter()
@@ -49,6 +50,10 @@ class AdminSmtpUpdate(BaseModel):
smtp_use_tls: bool = True smtp_use_tls: bool = True
class AdminThemesUpdate(BaseModel):
themes: list[str]
def validate_smtp_values(payload: AdminSmtpUpdate, current: dict | None = None) -> dict: def validate_smtp_values(payload: AdminSmtpUpdate, current: dict | None = None) -> dict:
smtp_host = payload.smtp_host.strip() smtp_host = payload.smtp_host.strip()
smtp_from = payload.smtp_from.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()) 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') @router.put('/smtp')
def update_admin_smtp_settings(payload: AdminSmtpUpdate, _: dict = Depends(require_admin)): def update_admin_smtp_settings(payload: AdminSmtpUpdate, _: dict = Depends(require_admin)):
current = get_smtp_settings() current = get_smtp_settings()
+7
View File
@@ -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.link_service import list_public_links, list_public_users
from backend.app.services.token_service import validate_token from backend.app.services.token_service import validate_token
from backend.app.services.theme_service import THEMES, get_enabled_themes
router = APIRouter() router = APIRouter()
optional_bearer = HTTPBearer(auto_error=False) optional_bearer = HTTPBearer(auto_error=False)
@@ -16,6 +17,12 @@ def public_users():
return list_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')
@router.get('/feed/{username}') @router.get('/feed/{username}')
def public_feed( def public_feed(
+3 -3
View File
@@ -35,14 +35,14 @@ def save_smtp_settings(values: dict) -> None:
conn.commit() conn.commit()
def smtp_configured() -> bool: def smtp_configured(smtp_values: dict | None = None) -> bool:
smtp = get_smtp_settings() smtp = smtp_values or get_smtp_settings()
return bool(smtp['smtp_host'] and smtp['smtp_from']) return bool(smtp['smtp_host'] and smtp['smtp_from'])
def send_message(email: str, subject: str, body: str, smtp_values: dict | None = None) -> None: def send_message(email: str, subject: str, body: str, smtp_values: dict | None = None) -> None:
smtp = smtp_values or get_smtp_settings() 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') raise RuntimeError('SMTP is not configured; set LINKLOG_SMTP_HOST and LINKLOG_SMTP_FROM')
message = EmailMessage() message = EmailMessage()
+46
View File
@@ -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
+28 -1
View File
@@ -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 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(): def test_admin_can_save_and_validate_smtp_settings():
headers = login_headers() headers = login_headers()
original = get_smtp_settings() 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={ response = client.put('/api/admin/smtp', headers=headers, json={
'smtp_host': 'smtp.example.com', 'smtp_host': 'smtp.example.com',
'smtp_port': 587, 'smtp_port': 587,
@@ -97,7 +113,18 @@ def test_admin_can_save_and_validate_smtp_settings():
'smtp_use_tls': False, '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(): def test_admin_reports_smtp_validation_errors():
+34 -1
View File
@@ -11,6 +11,9 @@ const adminAuthNotice = document.querySelector('#admin-auth-notice');
const smtpForm = document.querySelector('#smtp-form'); const smtpForm = document.querySelector('#smtp-form');
const smtpTestButton = document.querySelector('#smtp-test-button'); const smtpTestButton = document.querySelector('#smtp-test-button');
const smtpStatus = document.querySelector('#smtp-status'); 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 smtpNextAllowedAt = null;
let smtpTimerHandle = null; let smtpTimerHandle = null;
const accessToken = localStorage.getItem('linklogAccessToken'); 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) { function renderUsers(users) {
userList.replaceChildren(...users.map((user) => { userList.replaceChildren(...users.map((user) => {
const row = document.createElement('div'); const row = document.createElement('div');
@@ -185,7 +204,7 @@ async function loadAdminState() {
return; return;
} }
await Promise.all([loadUsers(), loadPlugins(), loadLabels(), loadSmtpSettings()]); await Promise.all([loadUsers(), loadPlugins(), loadLabels(), loadSmtpSettings(), loadThemes()]);
showAdminState(true); showAdminState(true);
} }
@@ -312,6 +331,19 @@ smtpTestButton.addEventListener('click', async () => {
updateSmtpTimer(); 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) => { loadAdminState().catch((error) => {
showAdminState(false); showAdminState(false);
adminAuthNotice.textContent = accessToken adminAuthNotice.textContent = accessToken
@@ -322,5 +354,6 @@ loadAdminState().catch((error) => {
pluginList.textContent = ''; pluginList.textContent = '';
adminLabelList.textContent = ''; adminLabelList.textContent = '';
smtpForm.reset(); smtpForm.reset();
themesForm.reset();
}); });
})(); })();
+99 -3
View File
@@ -23,6 +23,78 @@
--shadow: 0 18px 50px rgba(17, 17, 27, 0.28); --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, *::before,
*::after { *::after {
@@ -91,7 +163,7 @@ body::selection {
.site-logo { .site-logo {
display: block; display: block;
width: min(260px, 70vw); width: min(260px, 70vw);
height: 150px; height: 80px;
margin-bottom: 12px; margin-bottom: 12px;
object-fit: contain; object-fit: contain;
object-position: left center; object-position: left center;
@@ -124,6 +196,17 @@ body::selection {
position: relative; 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 { .menu-toggle {
min-width: 0; min-width: 0;
padding: 10px 13px; padding: 10px 13px;
@@ -237,7 +320,7 @@ main.container {
gap: 14px; gap: 14px;
margin: 0 0 24px; margin: 0 0 24px;
padding: 16px; padding: 16px;
background: rgba(24, 24, 37, 0.72); background: var(--surface-0);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: 12px;
} }
@@ -302,6 +385,19 @@ button:focus-visible {
max-width: 560px; 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 { .settings-panel textarea {
min-height: 110px; min-height: 110px;
resize: vertical; resize: vertical;
@@ -522,7 +618,7 @@ button:disabled {
.link-item { .link-item {
padding: 11px; padding: 11px;
background: rgba(49, 50, 68, 0.84); background: var(--surface-0);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: 12px;
box-shadow: var(--shadow); box-shadow: var(--shadow);
+28
View File
@@ -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();
+1
View File
@@ -56,5 +56,6 @@
<footer class="site-footer">Copyright © 2026 Olaf Kolkman · <a href="https://git.kolkman.org/olaf/Link-Log">git.kolkman.org/LinkLog</a></footer> <footer class="site-footer">Copyright © 2026 Olaf Kolkman · <a href="https://git.kolkman.org/olaf/Link-Log">git.kolkman.org/LinkLog</a></footer>
<script src="/static/auth-header.js?v=3"></script> <script src="/static/auth-header.js?v=3"></script>
<script src="/static/logout.js?v=3"></script> <script src="/static/logout.js?v=3"></script>
<script src="/static/theme.js?v=1"></script>
</body> </body>
</html> </html>
+11 -1
View File
@@ -98,17 +98,27 @@
<label class="checkbox-label"> <label class="checkbox-label">
<input name="smtp_use_tls" type="checkbox" /> Use STARTTLS <input name="smtp_use_tls" type="checkbox" /> Use STARTTLS
</label> </label>
<button type="submit">Save SMTP settings</button>
<button id="smtp-test-button" type="button">Send validation email</button> <button id="smtp-test-button" type="button">Send validation email</button>
<p id="smtp-status" class="status" role="status"></p> <p id="smtp-status" class="status" role="status"></p>
<button type="submit">Save SMTP settings</button>
</form> </form>
</section> </section>
<section class="link-item settings-panel">
<h2>Available themes</h2>
<p>Choose the themes visitors may use.</p>
<form id="themes-form">
<div id="theme-options" class="theme-options" aria-live="polite">Loading themes...</div>
<button type="submit">Save themes</button>
<p id="theme-status" class="status" role="status"></p>
</form>
</section>
</div> </div>
</main> </main>
<footer class="site-footer">Copyright © 2026 Olaf Kolkman · <a href="https://git.kolkman.org/olaf/Link-Log">git.kolkman.org/LinkLog</a></footer> <footer class="site-footer">Copyright © 2026 Olaf Kolkman · <a href="https://git.kolkman.org/olaf/Link-Log">git.kolkman.org/LinkLog</a></footer>
<script src="/static/auth-header.js?v=3"></script> <script src="/static/auth-header.js?v=3"></script>
<script src="/static/logout.js?v=2"></script> <script src="/static/logout.js?v=2"></script>
<script src="/static/theme.js?v=1"></script>
<script src="/static/admin.js?v=5"></script> <script src="/static/admin.js?v=5"></script>
</body> </body>
</html> </html>
+1
View File
@@ -79,6 +79,7 @@
<script src="/static/auth-header.js?v=3"></script> <script src="/static/auth-header.js?v=3"></script>
<script src="/static/logout.js?v=3"></script> <script src="/static/logout.js?v=3"></script>
<script src="/static/theme.js?v=1"></script>
<script src="/static/feed.js?v=9"></script> <script src="/static/feed.js?v=9"></script>
</body> </body>
</html> </html>
+1
View File
@@ -47,6 +47,7 @@
<footer class="site-footer">Copyright © 2026 Olaf Kolkman · <a href="https://git.kolkman.org/olaf/Link-Log">git.kolkman.org/LinkLog</a></footer> <footer class="site-footer">Copyright © 2026 Olaf Kolkman · <a href="https://git.kolkman.org/olaf/Link-Log">git.kolkman.org/LinkLog</a></footer>
<script src="/static/auth-header.js?v=3"></script> <script src="/static/auth-header.js?v=3"></script>
<script src="/static/logout.js?v=3"></script> <script src="/static/logout.js?v=3"></script>
<script src="/static/theme.js?v=1"></script>
<script src="/static/labels.js?v=1"></script> <script src="/static/labels.js?v=1"></script>
</body> </body>
</html> </html>
+1
View File
@@ -54,6 +54,7 @@
<footer class="site-footer">Copyright © 2026 Olaf Kolkman · <a href="https://git.kolkman.org/olaf/Link-Log">git.kolkman.org/LinkLog</a></footer> <footer class="site-footer">Copyright © 2026 Olaf Kolkman · <a href="https://git.kolkman.org/olaf/Link-Log">git.kolkman.org/LinkLog</a></footer>
<script src="/static/auth-header.js?v=3"></script> <script src="/static/auth-header.js?v=3"></script>
<script src="/static/logout.js?v=3"></script> <script src="/static/logout.js?v=3"></script>
<script src="/static/theme.js?v=1"></script>
<script src="/static/login.js"></script> <script src="/static/login.js"></script>
</body> </body>
</html> </html>
+1
View File
@@ -19,6 +19,7 @@
</form> </form>
</section> </section>
</main> </main>
<script src="/static/theme.js?v=1"></script>
<script src="/static/reset-password.js"></script> <script src="/static/reset-password.js"></script>
</body> </body>
</html> </html>
+1
View File
@@ -43,6 +43,7 @@
</section> </section>
</main> </main>
<footer class="site-footer">Copyright © 2026 Olaf Kolkman</footer> <footer class="site-footer">Copyright © 2026 Olaf Kolkman</footer>
<script src="/static/theme.js?v=1"></script>
<script src="/static/setup.js"></script> <script src="/static/setup.js"></script>
</body> </body>
</html> </html>
+1
View File
@@ -97,6 +97,7 @@
<footer class="site-footer">Copyright © 2026 Olaf Kolkman · <a href="https://git.kolkman.org/olaf/Link-Log">git.kolkman.org/LinkLog</a></footer> <footer class="site-footer">Copyright © 2026 Olaf Kolkman · <a href="https://git.kolkman.org/olaf/Link-Log">git.kolkman.org/LinkLog</a></footer>
<script src="/static/auth-header.js?v=3"></script> <script src="/static/auth-header.js?v=3"></script>
<script src="/static/logout.js?v=2"></script> <script src="/static/logout.js?v=2"></script>
<script src="/static/theme.js?v=1"></script>
<script src="/static/profile.js?v=5"></script> <script src="/static/profile.js?v=5"></script>
</body> </body>
</html> </html>