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:
+86
-88
@@ -6,10 +6,14 @@ const sortSelect = document.getElementById('sort-select');
|
||||
const userFilter = document.getElementById('user-filter');
|
||||
const tagFilter = document.getElementById('tag-filter');
|
||||
const searchInput = document.getElementById('search-input');
|
||||
const pageSizeSelect = document.getElementById('page-size-select');
|
||||
const paginationEl = document.getElementById('pagination');
|
||||
|
||||
const cookieName = 'linklog-feed-preferences';
|
||||
const accessToken = localStorage.getItem('linklogAccessToken');
|
||||
let availableTags = [];
|
||||
let pageSizes = [25, 100, 250];
|
||||
let currentPage = 1;
|
||||
|
||||
function createAvatar(user) {
|
||||
const avatar = document.createElement('div');
|
||||
@@ -52,70 +56,6 @@ function writePreferences(pref) {
|
||||
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) {
|
||||
const date = new Date(value);
|
||||
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}`;
|
||||
}
|
||||
|
||||
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() {
|
||||
const response = await fetch('/api/public/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) {
|
||||
button.disabled = true;
|
||||
const response = await fetch(`/api/links/${encodeURIComponent(item.id)}/mastodon`, {
|
||||
@@ -321,35 +309,30 @@ function showEditForm(article, item) {
|
||||
article.appendChild(form);
|
||||
}
|
||||
|
||||
async function loadFeed(selectedTag = null) {
|
||||
async function loadFeed() {
|
||||
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';
|
||||
const response = await fetch(endpoint, {
|
||||
const response = await fetch(`${path}?${params.toString()}`, {
|
||||
headers: accessToken ? {Authorization: `Bearer ${accessToken}`} : {},
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
let items = data || [];
|
||||
const pref = readPreferences();
|
||||
const activeTag = selectedTag ?? pref.tag;
|
||||
|
||||
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);
|
||||
currentPage = data.page || 1;
|
||||
renderFeed(data.items || [], !routeUser);
|
||||
renderPagination(currentPage, data.total_pages || 1);
|
||||
}
|
||||
|
||||
function syncPreferences() {
|
||||
@@ -362,6 +345,7 @@ function syncPreferences() {
|
||||
sortSelect.addEventListener('change', (event) => {
|
||||
const next = { ...readPreferences(), sort: event.target.value };
|
||||
writePreferences(next);
|
||||
currentPage = 1;
|
||||
loadFeed();
|
||||
});
|
||||
|
||||
@@ -375,15 +359,29 @@ function syncPreferences() {
|
||||
tagFilter.addEventListener('change', (event) => {
|
||||
const next = { ...readPreferences(), tag: event.target.value };
|
||||
writePreferences(next);
|
||||
loadFeed(event.target.value);
|
||||
currentPage = 1;
|
||||
loadFeed();
|
||||
});
|
||||
|
||||
let searchDebounce;
|
||||
searchInput.addEventListener('input', (event) => {
|
||||
const next = { ...readPreferences(), search: event.target.value };
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
padding: 11px;
|
||||
background: var(--surface-0);
|
||||
|
||||
@@ -61,6 +61,10 @@
|
||||
Search
|
||||
<input id="search-input" type="search" placeholder="Search links" autocomplete="off" aria-label="Search links" />
|
||||
</label>
|
||||
<label>
|
||||
Per page
|
||||
<select id="page-size-select"></select>
|
||||
</label>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -84,12 +88,13 @@
|
||||
</section>
|
||||
{% endif %}
|
||||
<section id="feed" class="feed" aria-live="polite"></section>
|
||||
<nav id="pagination" class="pagination" aria-label="Feed pages"></nav>
|
||||
</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>
|
||||
|
||||
<script src="/static/auth-header.js?v=3"></script>
|
||||
<script src="/static/logout.js?v=3"></script>
|
||||
<script src="/static/theme.js?v=1"></script>
|
||||
<script src="/static/feed.js?v=13"></script>
|
||||
<script src="/static/feed.js?v=14"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "0.2.1
|
||||
"version": "0.3.0"
|
||||
}
|
||||
Reference in New Issue
Block a user