Pagination added and search and filtering moved to backend
Build LinkLog Development Image / development-image (push) Successful in 22s
Build LinkLog Development Image / development-image (push) Successful in 22s
This commit is contained in:
+2
-1
@@ -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.
|
||||||
+13
-2
@@ -1,7 +1,18 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
## Version v0.2.1
|
## Version v0.3.0
|
||||||
### Fixed
|
### 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
|
||||||
* 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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());
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"version": "0.2.1
|
"version": "0.3.0"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user