3 Commits
Author SHA1 Message Date
olaf 4854eb8df6 eyecandy improvements
Build LinkLog Development Image / development-image (push) Successful in 10s
2026-08-26 13:46:33 +02:00
olaf 134e463a81 eyecandy 2026-08-26 13:11:10 +02:00
olaf 72a4741cac Added a theme selector 2026-08-26 12:54:38 +02:00
22 changed files with 389 additions and 37 deletions
+3 -11
View File
@@ -47,17 +47,7 @@ On Windows PowerShell, activate the environment with:
.venv\Scripts\Activate.ps1
```
The SQLite database is created automatically at `backend/data/linklog.db` when the application starts or when the auth router is imported. The starter accounts are:
| Username | Password | Role |
| --- | --- | --- |
| `alice` | `secret123` | administrator |
| `bob` | `secret123` | standard user |
These credentials are for development only. Change the authentication and seeding design before deploying publicly.
The SQLite database is created automatically at `backend/data/linklog.db` when the application starts. On a fresh installation, open `/setup` and create the first administrator. No default user accounts are created by the application. The setup page also collects SMTP settings and requires a successful test email before creating the administrator.
The database schema is versioned with SQLite `PRAGMA user_version`. Application startup applies all pending migrations in order, so updating the application does not require deleting an existing database. New schema changes should be added as a new numbered migration in `backend/app/database.py`; existing migration entries must remain unchanged.
## Run The Backend
@@ -269,6 +259,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
+50
View File
@@ -266,6 +266,56 @@ 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 tags in the LinkLog on the bottom left, move the date to the bottom right, and horizontally align the tags with the date.
### Assistant outcome
Grouped tags and dates into a shared bottom metadata row, placing tags on the left and the date on the right with responsive wrapping for narrow screens.
## 2026-08-26
### User
Continue todos.
### Assistant outcome
Reviewed the requirements and source for unfinished TODO markers. No implementation TODOs remain; corrected the README’s stale default-account instructions to match the current first-run administrator setup flow, then validated the project.
### User
Do not show `no comment provided`; leave the comment empty when no comment has been provided.
### Assistant outcome
Changed feed rendering to leave the comment area empty when a link has no comment, removing the `No comment provided` placeholder. Added a regression assertion and validated the change.
### User
Decrease the space between LinkLog items.
### Assistant outcome
Reduced the feed grid gap from 14px to 8px so link-log items sit closer together while preserving the existing item padding and responsive layout.
### User
Perform the next items on the todo list.
### Assistant outcome
No TODO markers remained, so addressed the next concrete requirements gap found during validation: user profile summaries now render on `/<user>/` pages even when that user has no links, by loading profile data independently from the feed.
### 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
+8
View File
@@ -130,6 +130,14 @@
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.
127. Show the tags in the linklog on the bottom left, move the date to the bottom right - horizontally align the tags with the date.
128. Continue todos.
129. Do not show 'no comment provided' but leave empty when no comment has been provided.
130. Decrease the space between link-log items.
131. Perform the next items on the todo list.
## Future entries
+19
View File
@@ -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()
+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.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(
+2 -8
View File
@@ -19,7 +19,7 @@ from backend.app.api.setup import has_administrator
from backend.app.api.user_config import router as user_config_router
from backend.app.core.config import settings
from backend.app.database import AVATARS_DIR
from backend.app.services.link_service import list_public_links
from backend.app.services.link_service import get_public_profile, list_public_links
logging.basicConfig(level=getattr(logging, settings.log_level, logging.INFO))
@@ -99,13 +99,7 @@ def health_check():
@app.get('/{username}/', response_class=HTMLResponse)
async def public_user_feed(request: Request, username: str):
feed = list_public_links(username)
profile = None
if feed:
profile = {
'username': feed[0]['username'],
'avatar_url': feed[0]['avatar_url'],
'bio': feed[0]['bio'],
}
profile = get_public_profile(username)
return templates.TemplateResponse(
request,
'feed.html',
+3 -3
View File
@@ -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()
+9
View File
@@ -127,6 +127,15 @@ def list_public_links(username: str | None = None):
return records
def get_public_profile(username: str) -> dict | None:
with get_connection() as conn:
row = conn.execute(
'SELECT username, avatar_url, bio FROM users WHERE username = ?',
(username,),
).fetchone()
return dict(row) if row else None
def update_link(
link_id: str,
user_id: str,
+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
+31 -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
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():
@@ -466,6 +493,9 @@ def test_public_and_admin_pages_render_html():
assert 'postToMastodon(item, mastodonButton)' in feed_script
assert 'tag.toLowerCase() === pref.tag.toLowerCase()' in feed_script
assert 'return `${date.getFullYear()} ${months[date.getMonth()]} ${date.getDate()} - ${hours}:${minutes}`' in feed_script
assert "entryMeta.className = 'entry-meta'" in feed_script
assert "meta.className = 'meta'" in feed_script
assert "comment.textContent = item.comment || ''" in feed_script
assert 'profileLink.href = `/${encodeURIComponent(username)}/`' in feed_script
assert 'edit-tag-options' in feed_script
assert 'new_tags' in feed_script
+34 -1
View File
@@ -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();
});
})();
+7 -3
View File
@@ -104,18 +104,22 @@ function renderFeed(items, showIdentity = true) {
const comment = document.createElement('div');
comment.className = 'comment';
comment.textContent = item.comment || 'No comment provided';
comment.textContent = item.comment || '';
const entryMeta = document.createElement('div');
entryMeta.className = 'entry-meta';
if (item.tags?.length) {
const tags = document.createElement('div');
tags.className = 'entry-tags';
tags.textContent = item.tags.join(' ');
article.appendChild(tags);
entryMeta.appendChild(tags);
}
const meta = document.createElement('div');
meta.className = 'meta';
meta.textContent = formatDate(item.created_at);
entryMeta.appendChild(meta);
if (item.is_owner && !showIdentity) {
const actions = document.createElement('div');
@@ -161,7 +165,7 @@ function renderFeed(items, showIdentity = true) {
}
article.appendChild(title);
article.appendChild(comment);
article.appendChild(meta);
article.appendChild(entryMeta);
feedEl.appendChild(article);
});
}
+123 -8
View File
@@ -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;
@@ -374,7 +470,7 @@ button:disabled {
.plugin-list,
.feed {
display: grid;
gap: 14px;
gap: 8px;
}
.plugin-row {
@@ -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);
@@ -536,7 +632,7 @@ button:disabled {
.link-item h2 {
margin: 0 0 8px;
color: var(--text);
font-size: 1.2rem;
font-size: 1rem;
line-height: 1.25;
}
@@ -545,7 +641,7 @@ button:disabled {
align-items: center;
float: right;
gap: 12px;
margin: 0 0 12px 18px;
margin: 0 0 2px 8px;
}
.user-link {
@@ -598,18 +694,27 @@ button:disabled {
}
.meta {
margin-top: 4px;
margin-left: auto;
color: var(--muted);
font-size: 0.82rem;
text-align: right;
}
.entry-tags {
margin-top: 12px;
color: var(--mauve);
font-size: 0.85rem;
font-weight: 600;
}
.entry-meta {
display: flex;
align-items: baseline;
justify-content: space-between;
clear: both;
gap: 16px;
margin-top: 14px;
}
@media (max-width: 600px) {
.container {
padding: 0 14px;
@@ -663,6 +768,16 @@ button:disabled {
padding: 14px;
}
.entry-meta {
align-items: flex-start;
flex-wrap: wrap;
gap: 4px 12px;
}
.meta {
margin-left: auto;
}
.toolbar label,
select,
input,
+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>
<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>
+11 -1
View File
@@ -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>
+1
View File
@@ -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>
+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>
<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>
+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>
<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>
+1
View File
@@ -19,6 +19,7 @@
</form>
</section>
</main>
<script src="/static/theme.js?v=1"></script>
<script src="/static/reset-password.js"></script>
</body>
</html>
+1
View File
@@ -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>
+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>
<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>