Compare commits
3
Commits
fec4c8def5
...
4854eb8df6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4854eb8df6 | ||
|
|
134e463a81 | ||
|
|
72a4741cac |
@@ -47,17 +47,7 @@ On Windows PowerShell, activate the environment with:
|
|||||||
.venv\Scripts\Activate.ps1
|
.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:
|
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.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
| 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 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.
|
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
|
## 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`.
|
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
|
||||||
|
|||||||
@@ -266,6 +266,56 @@ 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 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
|
### 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
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,14 @@
|
|||||||
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.
|
||||||
|
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
|
## Future entries
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
+2
-8
@@ -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.api.user_config import router as user_config_router
|
||||||
from backend.app.core.config import settings
|
from backend.app.core.config import settings
|
||||||
from backend.app.database import AVATARS_DIR
|
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))
|
logging.basicConfig(level=getattr(logging, settings.log_level, logging.INFO))
|
||||||
|
|
||||||
@@ -99,13 +99,7 @@ def health_check():
|
|||||||
@app.get('/{username}/', response_class=HTMLResponse)
|
@app.get('/{username}/', response_class=HTMLResponse)
|
||||||
async def public_user_feed(request: Request, username: str):
|
async def public_user_feed(request: Request, username: str):
|
||||||
feed = list_public_links(username)
|
feed = list_public_links(username)
|
||||||
profile = None
|
profile = get_public_profile(username)
|
||||||
if feed:
|
|
||||||
profile = {
|
|
||||||
'username': feed[0]['username'],
|
|
||||||
'avatar_url': feed[0]['avatar_url'],
|
|
||||||
'bio': feed[0]['bio'],
|
|
||||||
}
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
'feed.html',
|
'feed.html',
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -127,6 +127,15 @@ def list_public_links(username: str | None = None):
|
|||||||
return records
|
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(
|
def update_link(
|
||||||
link_id: str,
|
link_id: str,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
|
|||||||
@@ -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
|
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():
|
||||||
@@ -466,6 +493,9 @@ def test_public_and_admin_pages_render_html():
|
|||||||
assert 'postToMastodon(item, mastodonButton)' in feed_script
|
assert 'postToMastodon(item, mastodonButton)' in feed_script
|
||||||
assert 'tag.toLowerCase() === pref.tag.toLowerCase()' 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 '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 'profileLink.href = `/${encodeURIComponent(username)}/`' in feed_script
|
||||||
assert 'edit-tag-options' in feed_script
|
assert 'edit-tag-options' in feed_script
|
||||||
assert 'new_tags' in feed_script
|
assert 'new_tags' in feed_script
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -104,18 +104,22 @@ function renderFeed(items, showIdentity = true) {
|
|||||||
|
|
||||||
const comment = document.createElement('div');
|
const comment = document.createElement('div');
|
||||||
comment.className = 'comment';
|
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) {
|
if (item.tags?.length) {
|
||||||
const tags = document.createElement('div');
|
const tags = document.createElement('div');
|
||||||
tags.className = 'entry-tags';
|
tags.className = 'entry-tags';
|
||||||
tags.textContent = item.tags.join(' ');
|
tags.textContent = item.tags.join(' ');
|
||||||
article.appendChild(tags);
|
entryMeta.appendChild(tags);
|
||||||
}
|
}
|
||||||
|
|
||||||
const meta = document.createElement('div');
|
const meta = document.createElement('div');
|
||||||
meta.className = 'meta';
|
meta.className = 'meta';
|
||||||
meta.textContent = formatDate(item.created_at);
|
meta.textContent = formatDate(item.created_at);
|
||||||
|
entryMeta.appendChild(meta);
|
||||||
|
|
||||||
if (item.is_owner && !showIdentity) {
|
if (item.is_owner && !showIdentity) {
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
@@ -161,7 +165,7 @@ function renderFeed(items, showIdentity = true) {
|
|||||||
}
|
}
|
||||||
article.appendChild(title);
|
article.appendChild(title);
|
||||||
article.appendChild(comment);
|
article.appendChild(comment);
|
||||||
article.appendChild(meta);
|
article.appendChild(entryMeta);
|
||||||
feedEl.appendChild(article);
|
feedEl.appendChild(article);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+123
-8
@@ -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;
|
||||||
@@ -374,7 +470,7 @@ button:disabled {
|
|||||||
.plugin-list,
|
.plugin-list,
|
||||||
.feed {
|
.feed {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plugin-row {
|
.plugin-row {
|
||||||
@@ -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);
|
||||||
@@ -536,7 +632,7 @@ button:disabled {
|
|||||||
.link-item h2 {
|
.link-item h2 {
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 1.2rem;
|
font-size: 1rem;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,7 +641,7 @@ button:disabled {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
float: right;
|
float: right;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin: 0 0 12px 18px;
|
margin: 0 0 2px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-link {
|
.user-link {
|
||||||
@@ -598,18 +694,27 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.meta {
|
.meta {
|
||||||
margin-top: 4px;
|
margin-left: auto;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-tags {
|
.entry-tags {
|
||||||
margin-top: 12px;
|
|
||||||
color: var(--mauve);
|
color: var(--mauve);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 600;
|
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) {
|
@media (max-width: 600px) {
|
||||||
.container {
|
.container {
|
||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
@@ -663,6 +768,16 @@ button:disabled {
|
|||||||
padding: 14px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.entry-meta {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.toolbar label,
|
.toolbar label,
|
||||||
select,
|
select,
|
||||||
input,
|
input,
|
||||||
|
|||||||
@@ -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>
|
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user