Pagination added and search and filtering moved to backend
Build LinkLog Development Image / development-image (push) Successful in 22s

This commit is contained in:
2026-09-06 10:01:09 +02:00
parent 9c0416f4bb
commit 71f4451b34
16 changed files with 361 additions and 124 deletions
+2 -1
View File
@@ -8,7 +8,6 @@ APP_HEALTHCHECK_TIMEOUT=5s
APP_HEALTHCHECK_START_PERIOD=10s APP_HEALTHCHECK_START_PERIOD=10s
APP_HEALTHCHECK_RETRIES=3 APP_HEALTHCHECK_RETRIES=3
LINKLOG_APP_NAME=LinkLog LINKLOG_APP_NAME=LinkLog
LINKLOG_VERSION=0.1.1
LINKLOG_SECRET_KEY=replace-with-a-long-random-secret LINKLOG_SECRET_KEY=replace-with-a-long-random-secret
LINKLOG_DATA_ENCRYPTION_KEY=generate-with-python-cryptography-fernet-key LINKLOG_DATA_ENCRYPTION_KEY=generate-with-python-cryptography-fernet-key
LINKLOG_TOKEN_EXPIRY_MINUTES=15 LINKLOG_TOKEN_EXPIRY_MINUTES=15
@@ -27,6 +26,8 @@ LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES=10
LINKLOG_LOG_LEVEL=INFO LINKLOG_LOG_LEVEL=INFO
# Optional comma-separated override. Leave empty to use the built-in list. # Optional comma-separated override. Leave empty to use the built-in list.
LINKLOG_TRACKING_PARAMS= LINKLOG_TRACKING_PARAMS=
# Comma-separated feed page sizes offered to users, first value is the default. Up to 5 values are used.
LINKLOG_FRONTEND_LOADPOSTS=25,100,250
# Keep the default path when using the named linklog_data volume. # Keep the default path when using the named linklog_data volume.
LINKLOG_DATABASE_PATH=/app/backend/data/linklog.db LINKLOG_DATABASE_PATH=/app/backend/data/linklog.db
@@ -0,0 +1,27 @@
---
name: changelog-maintenance
description: "Use when: completing LinkLog work that changes user-facing behavior (features, fixes, UI/UX, configuration, API responses). Update CHANGELOG.MD's current unreleased version section, and bump frontend/version.json when the user explicitly requests a version bump or release."
---
# LinkLog Changelog Maintenance
## Purpose
`CHANGELOG.MD` is LinkLog's user-facing history of releases. Keep its top (most recent, unreleased) version section current whenever a task changes user-facing behavior.
## Workflow
1. Complete the user's requested work and its relevant validation first.
2. Decide if the change is changelog-worthy (see Scope below). Skip internal-only changes.
3. Open `CHANGELOG.MD` and find the topmost `## Version vX.Y.Z` section — this is the current unreleased version being accumulated. Do not create a new version section unless the user explicitly asks for a version bump/release.
4. Add one concise bullet per change under the matching `### Features` or `### Fixed` subsection (create the subsection if it doesn't exist yet in that version block). Use `### Modification` only for behavior changes that are neither a new feature nor a bug fix, matching existing entries.
5. Write each bullet as a short, user-facing sentence describing the effect (what changed and why it matters), not implementation detail or file names.
6. Do not edit, reorder, or remove bullets from older `## Version` sections. Only append to the current unreleased section.
7. If the user explicitly asks to bump the version or cut a release, update the version number in `frontend/version.json` (valid JSON, e.g. `{"version": "0.3.1"}`) to match the `## Version vX.Y.Z` heading, and start a new top section for subsequent changes.
8. Before finalizing, re-read `CHANGELOG.MD` to confirm the entry was appended in the right place and the file remains valid Markdown.
## Scope
Changelog-worthy: new features, bug fixes, UI/UX changes, configuration options, API/behavior changes visible to users or operators.
Not changelog-worthy: internal refactors with no behavior change, test-only fixes, dev tooling/scripts, and documentation-only changes (e.g. `VIBE/`, `DATABASE.md`, skill files) — unless the user asks otherwise.
+12 -1
View File
@@ -1,7 +1,18 @@
# Changelog # Changelog
## Version v0.2.1 ## Version v0.3.0
### Features
* Implemented toolbar search functionality, using Google-like syntax (implicit AND, quoted phrases, `OR`, `-` exclusions, and the `site:` operator)
* The feed now loads a user-selectable number of entries per page (default 25, configurable via `LINKLOG_FRONTEND_LOADPOSTS`) with Previous/Next and numbered page navigation
* Moved tag/user filtering, search, and sorting to the backend, so the feed API returns only the matching, paginated results
### Fixed ### Fixed
* Restored tag and user filtering on the feed: the decorative header arc no longer intercepts clicks on the filter dropdowns * Restored tag and user filtering on the feed: the decorative header arc no longer intercepts clicks on the filter dropdowns
* Fixed the toolbar search input being wider than its label at narrow (Firefox) widths
* Swapped the toolbar's search and tag filter positions for a more logical layout
* Mastodon posts now read "Logged on <date> from: <url>" instead of "From: <url>", with the timestamp explicitly marked as UTC
* The feed pagination control no longer overflows the page width and uses a less visually dominant, ghost-button style
## Version v0.2.0 ## Version v0.2.0
+24
View File
@@ -1568,4 +1568,28 @@ fix the Mastodon timestamp assertion to expect UTC
### Assistant outcome ### Assistant outcome
Updated the Mastodon posting regression assertion to expect the formatter's explicit `UTC` marker in the `Logged on` timestamp. The targeted test passed with `1 passed, 35 deselected`. Updated the Mastodon posting regression assertion to expect the formatter's explicit `UTC` marker in the `Logged on` timestamp. The targeted test passed with `1 passed, 35 deselected`.
### User
Use the APIs to, In the current runtime (linklog.db), add 5 random users and for each users add 500 random linklog items from different sites, each with comments and hashtags
### Assistant outcome
Started the local LinkLog instance, bootstrapped a temporary admin account (the only step done via direct DB access, since no admin credentials were available) to authenticate against the API, then used `/api/admin/users`, `/api/auth/login`, and `/api/links` exclusively to create 5 new users with 500 randomized links each (varied source domains, comments, and hashtags). Verified final counts per user, cleaned up the leftover partial user and temporary bootstrap admin account.
### User
the current implementation reads the whole feed at once.
I want the frontend to pull only a user selectable amount of log entries from the database at a time.
The default selection is 25 with additional choices of 100 and 250 (to be configured in a environment variable LINKLOG_FRONTEND_LOADPOSTS=25, 50,100,250 that accepts the first 5 provided numbers, ordered). The user can select the next, or previous button or click page 1, 2, 3, ... on the bottom of the current page.
If the user uses the filters and/or search in the toolbox then those should limit the entries the server presents, so the filters and search are applied on the server side.
### Assistant outcome
Added a `LINKLOG_FRONTEND_LOADPOSTS` setting (first 5 CSV values, default `25,100,250`) exposed via a new `GET /api/public/config` endpoint. Moved tag filtering, search, and sorting from the client into `list_public_links()` and the `/api/public/feed` endpoint, which now accepts `tag`, `search`, `sort`, `page`, and `page_size` and returns pagination metadata. Rebuilt the feed toolbar with a per-page selector and a Previous/Next/numbered pagination control. Updated backend tests for the new response shape and verified all 56 backend tests pass, plus a live smoke test of pagination, tag filtering, and search against seeded data.
### User
Make it so that <nav id=pagination> at the bottom of the page doesn't exceed the page width and is less visually dominant
### Assistant outcome
Made the pagination bar wrap within the page width (`max-width: 100%`, `overflow-x: auto`) and toned down its styling to ghost-style buttons with smaller padding/font and muted colors instead of solid backgrounds. Verified the updated CSS is served correctly from a rebuilt local container.
# Chat Log # Chat Log
+12
View File
@@ -300,6 +300,18 @@ Where date format is like: 2026 August 29 - 21:10
254. In firefox the search input field is wider (at 180px) then labe search control (at 135.5px) 254. In firefox the search input field is wider (at 180px) then labe search control (at 135.5px)
255. In the toolbar switch the search and tag filter's location 255. In the toolbar switch the search and tag filter's location
## 2026-09-06
256. Use the APIs to, In the current runtime (linklog.db), add 5 random users and for each users add 500 random linklog items from different sites, each with comments and hashtags
257. the current implementation reads the whole feed at once.
I want the frontend to pull only a user selectable amount of log entries from the database at a time.
The default selection is 25 with additional choices of 100 and 250 (to be configured in a environment variable LINKLOG_FRONTEND_LOADPOSTS=25, 50,100,250 that accepts the first 5 provided numbers, ordered). The user can select the next, or previous button or click page 1, 2, 3, ... on the bottom of the current page.
If the user uses the filters and/or search in the toolbox then those should limit the entries the server presents, so the filters and search are applied on the server side.
258. Make it so that <nav id=pagination> at the bottom of the page doesn't exceed the page width and is less visually dominant
## Future entries ## Future entries
Append each new user prompt here with its date and preserve the chronological order. Append each new user prompt here with its date and preserve the chronological order.
+1 -1
View File
@@ -187,4 +187,4 @@ def post_link_to_mastodon(
@router.get('/links') @router.get('/links')
def list_links(): def list_links():
return list_public_links() return list_public_links()['items']
+28 -4
View File
@@ -1,9 +1,12 @@
## Copyright © 2026 Olaf Kolkman ## Copyright © 2026 Olaf Kolkman
## SPDX-License-Identifier: GPL-3.0-or-later ## SPDX-License-Identifier: GPL-3.0-or-later
from fastapi import APIRouter, Depends import math
from fastapi import APIRouter, Depends, Query
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from backend.app.core.config import settings
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 from backend.app.services.theme_service import THEMES, get_enabled_themes
@@ -23,10 +26,20 @@ def public_themes():
return [{'id': theme, **THEMES[theme]} for theme in enabled] return [{'id': theme, **THEMES[theme]} for theme in enabled]
@router.get('/config')
def public_config():
return {'feed_page_sizes': settings.feed_page_sizes, 'default_page_size': settings.feed_page_sizes[0]}
@router.get('/feed') @router.get('/feed')
@router.get('/feed/{username}') @router.get('/feed/{username}')
def public_feed( def public_feed(
username: str | None = None, username: str | None = None,
tag: str | None = None,
search: str | None = None,
sort: str = 'newest',
page: int = Query(1, ge=1),
page_size: int | None = Query(None, ge=1),
credentials: HTTPAuthorizationCredentials | None = Depends(optional_bearer), credentials: HTTPAuthorizationCredentials | None = Depends(optional_bearer),
): ):
current_user_id = None current_user_id = None
@@ -34,8 +47,10 @@ def public_feed(
token_data = validate_token(credentials.credentials) token_data = validate_token(credentials.credentials)
if token_data: if token_data:
current_user_id = token_data['user_id'] current_user_id = token_data['user_id']
items = list_public_links(username) allowed_sizes = settings.feed_page_sizes
return [ effective_page_size = page_size if page_size in allowed_sizes else allowed_sizes[0]
result = list_public_links(username, tag=tag, search=search, sort=sort, page=page, page_size=effective_page_size)
items = [
{ {
'id': item['id'], 'id': item['id'],
'title': item['title'], 'title': item['title'],
@@ -52,5 +67,14 @@ def public_feed(
'created_at': item['created_at'], 'created_at': item['created_at'],
'mastodon_posted': bool(item['mastodon_posted']), 'mastodon_posted': bool(item['mastodon_posted']),
} }
for item in items for item in result['items']
] ]
total = result['total']
total_pages = max(1, math.ceil(total / effective_page_size))
return {
'items': items,
'total': total,
'page': page,
'page_size': effective_page_size,
'total_pages': total_pages,
}
+19
View File
@@ -53,6 +53,7 @@ class Settings:
mastodon_oauth_expiry_minutes: int = int(os.getenv('LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES', '10')) mastodon_oauth_expiry_minutes: int = int(os.getenv('LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES', '10'))
log_level: str = os.getenv('LINKLOG_LOG_LEVEL', 'INFO').upper() log_level: str = os.getenv('LINKLOG_LOG_LEVEL', 'INFO').upper()
tracking_params: list[str] = None tracking_params: list[str] = None
feed_page_sizes: list[int] = None
def __post_init__(self): def __post_init__(self):
if self.tracking_params is None: if self.tracking_params is None:
@@ -69,6 +70,24 @@ class Settings:
if configured_params if configured_params
else default_tracking_params else default_tracking_params
) )
if self.feed_page_sizes is None:
default_page_sizes = [25, 100, 250]
configured_sizes = os.getenv('LINKLOG_FRONTEND_LOADPOSTS')
parsed_sizes = []
if configured_sizes:
for item in configured_sizes.split(','):
item = item.strip()
if not item:
continue
try:
value = int(item)
except ValueError:
continue
if value > 0 and value not in parsed_sizes:
parsed_sizes.append(value)
if len(parsed_sizes) == 5:
break
self.feed_page_sizes = parsed_sizes or default_page_sizes
settings = Settings() settings = Settings()
+3 -5
View File
@@ -20,7 +20,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, validate_configuration from backend.app.core.config import settings, validate_configuration
from backend.app.database import AVATARS_DIR from backend.app.database import AVATARS_DIR
from backend.app.services.link_service import get_public_profile, list_public_links from backend.app.services.link_service import get_public_profile
logging.basicConfig(level=getattr(logging, settings.log_level, logging.INFO)) logging.basicConfig(level=getattr(logging, settings.log_level, logging.INFO))
validate_configuration(settings) validate_configuration(settings)
@@ -53,8 +53,7 @@ templates.env.globals['app_version'] = settings.version
async def public_root(request: Request): async def public_root(request: Request):
if not has_administrator(): if not has_administrator():
return RedirectResponse('/setup') return RedirectResponse('/setup')
feed = list_public_links() return templates.TemplateResponse(request, 'feed.html', {})
return templates.TemplateResponse(request, 'feed.html', {'feed': feed})
@app.get('/admin', response_class=HTMLResponse) @app.get('/admin', response_class=HTMLResponse)
@@ -117,10 +116,9 @@ def health_check():
@app.get('/{username}', response_class=HTMLResponse) @app.get('/{username}', response_class=HTMLResponse)
@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)
profile = get_public_profile(username) profile = get_public_profile(username)
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
'feed.html', 'feed.html',
{'feed': feed, 'profile': profile, 'user_filter': username}, {'profile': profile, 'user_filter': username},
) )
+77 -5
View File
@@ -3,6 +3,8 @@
from datetime import datetime, timezone from datetime import datetime, timezone
import json import json
import re
from urllib.parse import urlparse
from uuid import uuid4 from uuid import uuid4
from backend.app.core.security import clean_url from backend.app.core.security import clean_url
@@ -136,23 +138,93 @@ def find_owned_link_by_title(user_id: str, title: str) -> dict | None:
return record return record
def list_public_links(username: str | None = None): def _tokenize_search(value: str) -> list[str]:
return [group1 or group2 for group1, group2 in re.findall(r'"([^"]+)"|(\S+)', value or '')]
def _parse_search_query(value: str) -> dict:
groups = [[]]
excluded = []
sites = []
for token in _tokenize_search(value):
if token.upper() == 'OR':
groups.append([])
continue
is_excluded = token.startswith('-') and len(token) > 1
term = token[1:] if is_excluded else token
site_match = re.match(r'^site:(\S+)$', term, re.IGNORECASE)
if site_match and not is_excluded:
sites.append(site_match.group(1).lower())
continue
if is_excluded:
excluded.append(term.lower())
else:
groups[-1].append(term.lower())
return {'groups': [group for group in groups if group], 'excluded': excluded, 'sites': sites}
def _searchable_text(record: dict) -> str:
parts = [record.get('title'), record.get('url'), record.get('comment'), record.get('username'), *(record.get('tags') or [])]
return ' '.join(part for part in parts if part).lower()
def _matches_search(record: dict, query: str) -> bool:
if not query or not query.strip():
return True
parsed = _parse_search_query(query)
text = _searchable_text(record)
def site_matches(site: str) -> bool:
hostname = (urlparse(record.get('url') or '').hostname or '').lower()
return hostname == site or hostname.endswith(f'.{site}')
matches_site = all(site_matches(site) for site in parsed['sites'])
matches_group = not parsed['groups'] or any(all(term in text for term in group) for group in parsed['groups'])
excludes_term = any(term in text for term in parsed['excluded'])
return matches_site and matches_group and not excludes_term
def list_public_links(
username: str | None = None,
tag: str | None = None,
search: str | None = None,
sort: str = 'newest',
page: int = 1,
page_size: int | None = None,
) -> dict:
order = 'ASC' if sort == 'oldest' else 'DESC'
with get_connection() as conn: with get_connection() as conn:
rows = conn.execute( rows = conn.execute(
''' f'''
SELECT links.*, users.username, users.avatar_url, users.bio SELECT links.*, users.username, users.avatar_url, users.bio
FROM links FROM links
JOIN users ON users.id = links.user_id JOIN users ON users.id = links.user_id
WHERE links.is_public = 1 WHERE links.is_public = 1
AND (? IS NULL OR users.username = ?) AND (? IS NULL OR users.username = ?)
ORDER BY created_at DESC AND (
? IS NULL OR links.id IN (
SELECT link_tags.link_id FROM link_tags
JOIN tags ON tags.id = link_tags.tag_id
WHERE lower(tags.name) = lower(?)
)
)
ORDER BY links.created_at {order}
''', ''',
(username, username), (username, username, tag, tag),
).fetchall() ).fetchall()
records = [dict(row) for row in rows] records = [dict(row) for row in rows]
for record in records: for record in records:
record['tags'] = get_link_tags(conn, record['id']) record['tags'] = get_link_tags(conn, record['id'])
return records
if search:
records = [record for record in records if _matches_search(record, search)]
total = len(records)
if page_size:
start = max(page - 1, 0) * page_size
records = records[start:start + page_size]
return {'items': records, 'total': total}
def get_public_profile(username: str) -> dict | None: def get_public_profile(username: str) -> dict | None:
+2
View File
@@ -11,6 +11,8 @@ TEST_DATABASE_DIRECTORY = tempfile.TemporaryDirectory(prefix='linklog-tests-')
TEST_DATABASE_PATH = os.path.join(TEST_DATABASE_DIRECTORY.name, 'linklog.db') TEST_DATABASE_PATH = os.path.join(TEST_DATABASE_DIRECTORY.name, 'linklog.db')
os.environ['LINKLOG_DATABASE_PATH'] = TEST_DATABASE_PATH os.environ['LINKLOG_DATABASE_PATH'] = TEST_DATABASE_PATH
os.environ['LINKLOG_DATA_ENCRYPTION_KEY'] = 'L5M4sQYVjD1N7pT2Xk8R0aBcDeFgHiJkLmNoPqRsTuV=' os.environ['LINKLOG_DATA_ENCRYPTION_KEY'] = 'L5M4sQYVjD1N7pT2Xk8R0aBcDeFgHiJkLmNoPqRsTuV='
# Use a large page size so existing tests that expect the full feed keep working.
os.environ['LINKLOG_FRONTEND_LOADPOSTS'] = '1000'
@pytest.fixture(scope='session', autouse=True) @pytest.fixture(scope='session', autouse=True)
+13 -16
View File
@@ -607,7 +607,7 @@ def test_submit_link_stores_cleaned_url_and_public_feed():
data = response.json() data = response.json()
assert data['url'] == 'https://example.com/path?keep=yes' assert data['url'] == 'https://example.com/path?keep=yes'
feed = client.get('/api/public/feed').json() feed = client.get('/api/public/feed').json()['items']
matching = next(item for item in feed if item['id'] == data['id']) matching = next(item for item in feed if item['id'] == data['id'])
assert matching['comment'] == 'Interesting read' assert matching['comment'] == 'Interesting read'
assert matching['user']['username'] == 'alice' assert matching['user']['username'] == 'alice'
@@ -615,8 +615,8 @@ def test_submit_link_stores_cleaned_url_and_public_feed():
filtered_response = client.get('/api/public/feed/alice') filtered_response = client.get('/api/public/feed/alice')
assert filtered_response.status_code == 200 assert filtered_response.status_code == 200
assert all(item['user']['username'] == 'alice' for item in filtered_response.json()) assert all(item['user']['username'] == 'alice' for item in filtered_response.json()['items'])
assert client.get('/api/public/feed/does-not-exist').json() == [] assert client.get('/api/public/feed/does-not-exist').json()['items'] == []
users_response = client.get('/api/public/users') users_response = client.get('/api/public/users')
assert users_response.status_code == 200 assert users_response.status_code == 200
assert 'alice' in users_response.json() assert 'alice' in users_response.json()
@@ -645,7 +645,7 @@ def test_duplicate_link_updates_comment_tags_and_retriggers_plugins():
dispatch.assert_called_once() dispatch.assert_called_once()
assert dispatch.call_args.args[0]['comment'] == 'updated comment' assert dispatch.call_args.args[0]['comment'] == 'updated comment'
assert dispatch.call_args.args[0]['tags'] == ['#Second'] assert dispatch.call_args.args[0]['tags'] == ['#Second']
feed_item = next(item for item in client.get('/api/public/feed').json() if item['id'] == first.json()['id']) feed_item = next(item for item in client.get('/api/public/feed').json()['items'] if item['id'] == first.json()['id'])
assert feed_item['comment'] == 'updated comment' assert feed_item['comment'] == 'updated comment'
assert feed_item['tags'] == ['#Second'] assert feed_item['tags'] == ['#Second']
@@ -689,7 +689,7 @@ def test_links_support_tags_and_tag_filtering():
assert { assert {
'#Internet', '#Cybersecurity', '#Fediverse', '#Food', '#Photography', '#Music', '#AI' '#Internet', '#Cybersecurity', '#Fediverse', '#Food', '#Photography', '#Music', '#AI'
} <= set(tags) } <= set(tags)
tagged_feed = client.get('/api/public/feed').json() tagged_feed = client.get('/api/public/feed').json()['items']
tagged_item = next(item for item in tagged_feed if item['id'] == link_id) tagged_item = next(item for item in tagged_feed if item['id'] == link_id)
assert {tag.casefold() for tag in tagged_item['tags']} == {'#casepreserved', '#web'} assert {tag.casefold() for tag in tagged_item['tags']} == {'#casepreserved', '#web'}
@@ -725,12 +725,12 @@ def test_only_link_owner_can_edit_link():
}) })
assert unauthenticated.status_code == 401 assert unauthenticated.status_code == 401
anonymous_feed = client.get('/api/public/feed').json() anonymous_feed = client.get('/api/public/feed').json()['items']
anonymous_link = next(item for item in anonymous_feed if item['id'] == link_id) anonymous_link = next(item for item in anonymous_feed if item['id'] == link_id)
assert anonymous_link['is_owner'] is False assert anonymous_link['is_owner'] is False
assert anonymous_link['can_edit'] is False assert anonymous_link['can_edit'] is False
owner_feed = client.get('/api/public/feed', headers=owner_headers).json() owner_feed = client.get('/api/public/feed', headers=owner_headers).json()['items']
owner_link = next(item for item in owner_feed if item['id'] == link_id) owner_link = next(item for item in owner_feed if item['id'] == link_id)
assert owner_link['is_owner'] is True assert owner_link['is_owner'] is True
assert owner_link['can_edit'] is True assert owner_link['can_edit'] is True
@@ -830,17 +830,14 @@ def test_public_and_admin_pages_render_html():
assert 'class="panel-toggle-btn"' in profile_page assert 'class="panel-toggle-btn"' in profile_page
assert 'profile.js?v=6' in profile_page assert 'profile.js?v=6' in profile_page
feed_script = client.get('/static/feed.js?v=7').text feed_script = client.get('/static/feed.js?v=7').text
assert 'function parseSearchQuery(value)' in feed_script assert 'function loadFeed()' in feed_script
assert 'site:(\\S+)' in feed_script assert 'function renderPagination(page, totalPages)' in feed_script
assert 'token.toUpperCase() === \'OR\'' in feed_script
assert 'token.startsWith(\'-\')' in feed_script
assert 'matchesSearch(item, pref.search || \'\')' in feed_script
assert 'if (item.is_owner && !showIdentity)' in feed_script assert 'if (item.is_owner && !showIdentity)' in feed_script
assert 'deleteEntry(item, deleteButton)' in feed_script assert 'deleteEntry(item, deleteButton)' in feed_script
assert 'postToMastodon(item, mastodonButton)' in feed_script assert 'postToMastodon(item, mastodonButton)' in feed_script
assert 'tag.toLowerCase() === activeTag.toLowerCase()' in feed_script assert "params.set('tag', pref.tag)" in feed_script
assert 'loadFeed(event.target.value)' in feed_script assert 'currentPage = 1' in feed_script
assert 'Promise.all([loadUsers(), loadTags()]).then(() => loadFeed())' in feed_script assert "Promise.all([loadUsers(), loadTags()])" in feed_script
assert "fetch('/api/tags', {" in feed_script assert "fetch('/api/tags', {" in feed_script
assert 'Authorization: `Bearer ${accessToken}`' in feed_script assert 'Authorization: `Bearer ${accessToken}`' 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
@@ -900,7 +897,7 @@ def test_link_submission_posts_to_enabled_mastodon_plugin():
assert received['authorization'] == 'Bearer test-token' assert received['authorization'] == 'Bearer test-token'
assert received['content_type'] == 'application/x-www-form-urlencoded' assert received['content_type'] == 'application/x-www-form-urlencoded'
assert received['body'] == {'status': ['From my #LinkLog:\n\nA useful page\n\nWorth sharing\n\nLogged on 2026 August 29 - 21:10 UTC from: https://example.com/useful\n\n#python #web']} assert received['body'] == {'status': ['From my #LinkLog:\n\nA useful page\n\nWorth sharing\n\nLogged on 2026 August 29 - 21:10 UTC from: https://example.com/useful\n\n#python #web']}
posted_item = next(item for item in client.get('/api/public/feed/alice', headers=headers).json() if item['id'] == response.json()['id']) posted_item = next(item for item in client.get('/api/public/feed/alice', headers=headers).json()['items'] if item['id'] == response.json()['id'])
assert posted_item['mastodon_posted'] is True assert posted_item['mastodon_posted'] is True
finally: finally:
server.shutdown() server.shutdown()
+86 -88
View File
@@ -6,10 +6,14 @@ const sortSelect = document.getElementById('sort-select');
const userFilter = document.getElementById('user-filter'); const userFilter = document.getElementById('user-filter');
const tagFilter = document.getElementById('tag-filter'); const tagFilter = document.getElementById('tag-filter');
const searchInput = document.getElementById('search-input'); const searchInput = document.getElementById('search-input');
const pageSizeSelect = document.getElementById('page-size-select');
const paginationEl = document.getElementById('pagination');
const cookieName = 'linklog-feed-preferences'; const cookieName = 'linklog-feed-preferences';
const accessToken = localStorage.getItem('linklogAccessToken'); const accessToken = localStorage.getItem('linklogAccessToken');
let availableTags = []; let availableTags = [];
let pageSizes = [25, 100, 250];
let currentPage = 1;
function createAvatar(user) { function createAvatar(user) {
const avatar = document.createElement('div'); const avatar = document.createElement('div');
@@ -52,70 +56,6 @@ function writePreferences(pref) {
document.cookie = `${cookieName}=${value}; path=/; max-age=31536000`; document.cookie = `${cookieName}=${value}; path=/; max-age=31536000`;
} }
function tokenizeSearch(value) {
const tokens = [];
const pattern = /"([^"]+)"|(\S+)/g;
let match;
while ((match = pattern.exec(value)) !== null) {
tokens.push(match[1] || match[2]);
}
return tokens;
}
function parseSearchQuery(value) {
const groups = [[]];
const excluded = [];
const sites = [];
tokenizeSearch(value).forEach((token) => {
if (token.toUpperCase() === 'OR') {
groups.push([]);
return;
}
const isExcluded = token.startsWith('-') && token.length > 1;
const term = isExcluded ? token.slice(1) : token;
const siteMatch = term.match(/^site:(\S+)$/i);
if (siteMatch && !isExcluded) {
sites.push(siteMatch[1].toLowerCase());
return;
}
if (isExcluded) {
excluded.push(term.toLowerCase());
} else {
groups[groups.length - 1].push(term.toLowerCase());
}
});
return {groups: groups.filter((group) => group.length), excluded, sites};
}
function searchableText(item) {
return [
item.title,
item.url,
item.comment,
item.user?.username,
...(item.tags || []),
].filter(Boolean).join(' ').toLowerCase();
}
function matchesSearch(item, query) {
if (!query.trim()) return true;
const parsed = parseSearchQuery(query);
const text = searchableText(item);
const matchesSite = parsed.sites.every((site) => {
try {
const hostname = new URL(item.url).hostname.toLowerCase();
return hostname === site || hostname.endsWith(`.${site}`);
} catch (error) {
return false;
}
});
const matchesGroup = !parsed.groups.length || parsed.groups.some((group) => group.every((term) => text.includes(term)));
const excludesTerm = parsed.excluded.some((term) => text.includes(term));
return matchesSite && matchesGroup && !excludesTerm;
}
function formatDate(value) { function formatDate(value) {
const date = new Date(value); const date = new Date(value);
if (Number.isNaN(date.getTime())) return value || 'updated recently'; if (Number.isNaN(date.getTime())) return value || 'updated recently';
@@ -128,6 +68,17 @@ function formatDate(value) {
return `${date.getFullYear()} ${months[date.getMonth()]} ${date.getDate()} - ${hours}:${minutes}`; return `${date.getFullYear()} ${months[date.getMonth()]} ${date.getDate()} - ${hours}:${minutes}`;
} }
async function loadConfig() {
const response = await fetch('/api/public/config');
if (!response.ok) throw new Error('Could not load config');
const config = await response.json();
pageSizes = config.feed_page_sizes?.length ? config.feed_page_sizes : pageSizes;
const pref = readPreferences();
const selected = pageSizes.includes(pref.pageSize) ? pref.pageSize : pageSizes[0];
pageSizeSelect.replaceChildren(...pageSizes.map((size) => new Option(`${size} per page`, String(size))));
pageSizeSelect.value = String(selected);
}
async function loadUsers() { async function loadUsers() {
const response = await fetch('/api/public/users'); const response = await fetch('/api/public/users');
if (!response.ok) throw new Error('Could not load users'); if (!response.ok) throw new Error('Could not load users');
@@ -237,6 +188,43 @@ function renderFeed(items, showIdentity = true) {
}); });
} }
function renderPagination(page, totalPages) {
paginationEl.innerHTML = '';
if (totalPages <= 1) return;
const goToPage = (target) => {
currentPage = Math.min(Math.max(target, 1), totalPages);
loadFeed();
};
const makeButton = (label, target, options = {}) => {
const button = document.createElement('button');
button.type = 'button';
button.textContent = label;
if (options.active) button.classList.add('active');
if (options.disabled) button.disabled = true;
button.addEventListener('click', () => goToPage(target));
return button;
};
paginationEl.appendChild(makeButton('Previous', page - 1, {disabled: page <= 1}));
const pageNumbers = new Set([1, totalPages, page, page - 1, page + 1]);
let previous = null;
[...pageNumbers].filter((num) => num >= 1 && num <= totalPages).sort((a, b) => a - b).forEach((num) => {
if (previous !== null && num - previous > 1) {
const ellipsis = document.createElement('span');
ellipsis.className = 'pagination-ellipsis';
ellipsis.textContent = '…';
paginationEl.appendChild(ellipsis);
}
paginationEl.appendChild(makeButton(String(num), num, {active: num === page}));
previous = num;
});
paginationEl.appendChild(makeButton('Next', page + 1, {disabled: page >= totalPages}));
}
async function postToMastodon(item, button) { async function postToMastodon(item, button) {
button.disabled = true; button.disabled = true;
const response = await fetch(`/api/links/${encodeURIComponent(item.id)}/mastodon`, { const response = await fetch(`/api/links/${encodeURIComponent(item.id)}/mastodon`, {
@@ -321,35 +309,30 @@ function showEditForm(article, item) {
article.appendChild(form); article.appendChild(form);
} }
async function loadFeed(selectedTag = null) { async function loadFeed() {
const routeUser = document.body.dataset.userFilter; const routeUser = document.body.dataset.userFilter;
const endpoint = routeUser const pref = readPreferences();
const pageSize = pageSizes.includes(pref.pageSize) ? pref.pageSize : pageSizes[0];
const params = new URLSearchParams();
if (pref.tag) params.set('tag', pref.tag);
if (pref.search) params.set('search', pref.search);
params.set('sort', pref.sort || 'newest');
params.set('page', String(currentPage));
params.set('page_size', String(pageSize));
if (pref.user && !routeUser) params.set('username', pref.user);
const path = routeUser
? `/api/public/feed/${encodeURIComponent(routeUser)}` ? `/api/public/feed/${encodeURIComponent(routeUser)}`
: '/api/public/feed'; : '/api/public/feed';
const response = await fetch(endpoint, { const response = await fetch(`${path}?${params.toString()}`, {
headers: accessToken ? {Authorization: `Bearer ${accessToken}`} : {}, headers: accessToken ? {Authorization: `Bearer ${accessToken}`} : {},
}); });
const data = await response.json(); const data = await response.json();
let items = data || []; currentPage = data.page || 1;
const pref = readPreferences(); renderFeed(data.items || [], !routeUser);
const activeTag = selectedTag ?? pref.tag; renderPagination(currentPage, data.total_pages || 1);
if (pref.user && !routeUser) {
items = items.filter((item) => (item.user?.username || '').toLowerCase() === pref.user.toLowerCase());
}
if (activeTag) {
items = items.filter((item) => item.tags?.some((tag) => tag.toLowerCase() === activeTag.toLowerCase()));
}
items = items.filter((item) => matchesSearch(item, pref.search || ''));
if (pref.sort === 'oldest') {
items = [...items].reverse();
}
renderFeed(items, !routeUser);
} }
function syncPreferences() { function syncPreferences() {
@@ -362,6 +345,7 @@ function syncPreferences() {
sortSelect.addEventListener('change', (event) => { sortSelect.addEventListener('change', (event) => {
const next = { ...readPreferences(), sort: event.target.value }; const next = { ...readPreferences(), sort: event.target.value };
writePreferences(next); writePreferences(next);
currentPage = 1;
loadFeed(); loadFeed();
}); });
@@ -375,15 +359,29 @@ function syncPreferences() {
tagFilter.addEventListener('change', (event) => { tagFilter.addEventListener('change', (event) => {
const next = { ...readPreferences(), tag: event.target.value }; const next = { ...readPreferences(), tag: event.target.value };
writePreferences(next); writePreferences(next);
loadFeed(event.target.value); currentPage = 1;
loadFeed();
}); });
let searchDebounce;
searchInput.addEventListener('input', (event) => { searchInput.addEventListener('input', (event) => {
const next = { ...readPreferences(), search: event.target.value }; const next = { ...readPreferences(), search: event.target.value };
writePreferences(next); writePreferences(next);
currentPage = 1;
clearTimeout(searchDebounce);
searchDebounce = setTimeout(() => loadFeed(), 300);
});
pageSizeSelect.addEventListener('change', (event) => {
const next = { ...readPreferences(), pageSize: Number(event.target.value) };
writePreferences(next);
currentPage = 1;
loadFeed(); loadFeed();
}); });
} }
syncPreferences(); syncPreferences();
Promise.all([loadUsers(), loadTags()]).then(() => loadFeed()).catch(() => loadFeed()); loadConfig()
.then(() => Promise.all([loadUsers(), loadTags()]))
.then(() => loadFeed())
.catch(() => loadFeed());
+47
View File
@@ -886,6 +886,53 @@ button:disabled {
padding-bottom: 40px; padding-bottom: 40px;
} }
.pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 4px;
max-width: 100%;
margin: 0 0 40px;
overflow-x: auto;
}
.pagination button {
min-width: 28px;
padding: 3px 7px;
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
color: var(--muted);
font-size: 0.82rem;
line-height: 1.4;
cursor: pointer;
}
.pagination button:hover:not(:disabled) {
border-color: var(--border);
color: var(--text);
}
.pagination button:disabled {
opacity: 0.4;
cursor: default;
}
.pagination button.active {
background: var(--surface-1);
color: var(--text);
border-color: var(--border);
font-weight: 600;
}
.pagination .pagination-ellipsis {
padding: 3px 2px;
color: var(--muted);
font-size: 0.82rem;
opacity: 0.6;
}
.link-item { .link-item {
padding: 11px; padding: 11px;
background: var(--surface-0); background: var(--surface-0);
+6 -1
View File
@@ -61,6 +61,10 @@
Search Search
<input id="search-input" type="search" placeholder="Search links" autocomplete="off" aria-label="Search links" /> <input id="search-input" type="search" placeholder="Search links" autocomplete="off" aria-label="Search links" />
</label> </label>
<label>
Per page
<select id="page-size-select"></select>
</label>
</section> </section>
</div> </div>
</div> </div>
@@ -84,12 +88,13 @@
</section> </section>
{% endif %} {% endif %}
<section id="feed" class="feed" aria-live="polite"></section> <section id="feed" class="feed" aria-live="polite"></section>
<nav id="pagination" class="pagination" aria-label="Feed pages"></nav>
</main> </main>
<footer class="site-footer">LinkLog Version {{ app_version }}. Copyright © 2026 Olaf Kolkman · <a href="https://git.kolkman.org/olaf/Link-Log">git.kolkman.org/LinkLog</a></footer> <footer class="site-footer">LinkLog Version {{ app_version }}. 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/theme.js?v=1"></script>
<script src="/static/feed.js?v=13"></script> <script src="/static/feed.js?v=14"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"version": "0.2.1 "version": "0.3.0"
} }