Added a theme selector
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 /<user>/ page don't show the avatar and user name with each entry
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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():
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
<script src="/static/auth-header.js?v=3"></script>
|
||||
<script src="/static/logout.js?v=3"></script>
|
||||
<script src="/static/theme.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -98,17 +98,27 @@
|
||||
<label class="checkbox-label">
|
||||
<input name="smtp_use_tls" type="checkbox" /> Use STARTTLS
|
||||
</label>
|
||||
<button type="submit">Save SMTP settings</button>
|
||||
<button id="smtp-test-button" type="button">Send validation email</button>
|
||||
<p id="smtp-status" class="status" role="status"></p>
|
||||
<button type="submit">Save SMTP settings</button>
|
||||
|
||||
</form>
|
||||
</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>
|
||||
</main>
|
||||
<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/logout.js?v=2"></script>
|
||||
<script src="/static/theme.js?v=1"></script>
|
||||
<script src="/static/admin.js?v=5"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
|
||||
<script src="/static/auth-header.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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>
|
||||
<script src="/static/auth-header.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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>
|
||||
<script src="/static/auth-header.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/static/theme.js?v=1"></script>
|
||||
<script src="/static/reset-password.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -43,6 +43,7 @@
|
||||
</section>
|
||||
</main>
|
||||
<footer class="site-footer">Copyright © 2026 Olaf Kolkman</footer>
|
||||
<script src="/static/theme.js?v=1"></script>
|
||||
<script src="/static/setup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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>
|
||||
<script src="/static/auth-header.js?v=3"></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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user