// Copyright © 2026 Olaf Kolkman // SPDX-License-Identifier: GPL-3.0-or-later const feedEl = document.getElementById('feed'); 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 cookieName = 'linklog-feed-preferences'; const accessToken = localStorage.getItem('linklogAccessToken'); let availableTags = []; function createAvatar(user) { const avatar = document.createElement('div'); avatar.className = 'avatar'; const initial = (user?.username || 'U').slice(0, 1).toUpperCase(); avatar.textContent = initial; if (user?.avatar_url) { const image = document.createElement('img'); image.src = user.avatar_url; image.alt = `${user.username || 'User'} avatar`; image.addEventListener('error', () => { image.remove(); avatar.textContent = initial; }); avatar.textContent = ''; avatar.appendChild(image); } return avatar; } function readPreferences() { const cookie = document.cookie .split('; ') .find((row) => row.startsWith(`${cookieName}=`)); if (!cookie) { return { sort: 'newest', user: '' }; } try { return JSON.parse(decodeURIComponent(cookie.split('=')[1])); } catch (error) { return { sort: 'newest', user: '' }; } } function writePreferences(pref) { const value = encodeURIComponent(JSON.stringify(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'; const months = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ]; const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); return `${date.getFullYear()} ${months[date.getMonth()]} ${date.getDate()} - ${hours}:${minutes}`; } async function loadUsers() { const response = await fetch('/api/public/users'); if (!response.ok) throw new Error('Could not load users'); const users = await response.json(); const selectedUser = readPreferences().user; userFilter.replaceChildren(new Option('All users', ''), ...users.map((username) => new Option(username, username))); userFilter.value = users.includes(selectedUser) ? selectedUser : ''; } async function loadTags() { const response = await fetch('/api/tags', { headers: accessToken ? {Authorization: `Bearer ${accessToken}`} : {}, }); if (!response.ok) throw new Error('Could not load tags'); const tags = await response.json(); availableTags = tags; const selectedTag = readPreferences().tag || ''; tagFilter.replaceChildren(new Option('All tags', ''), ...tags.map((tag) => new Option(tag, tag))); tagFilter.value = tags.includes(selectedTag) ? selectedTag : ''; } function renderFeed(items, showIdentity = true) { feedEl.innerHTML = ''; if (!items.length) { feedEl.innerHTML = ''; return; } items.forEach((item) => { const article = document.createElement('article'); article.className = 'link-item'; const title = document.createElement('h2'); const link = document.createElement('a'); link.href = item.url; link.target = '_blank'; link.rel = 'noopener noreferrer'; link.textContent = item.title || item.url; title.appendChild(link); const comment = document.createElement('div'); comment.className = 'comment'; comment.textContent = item.comment || ''; const entryMeta = document.createElement('div'); entryMeta.className = 'entry-meta'; if (item.tags?.length) { const tags = document.createElement('div'); tags.className = 'entry-tags'; tags.textContent = item.tags.join(' '); entryMeta.appendChild(tags); } const meta = document.createElement('div'); meta.className = 'meta'; meta.textContent = formatDate(item.created_at); entryMeta.appendChild(meta); if (item.is_owner && !showIdentity) { const actions = document.createElement('div'); actions.className = 'entry-actions'; const editButton = document.createElement('button'); editButton.type = 'button'; editButton.className = 'edit-button'; editButton.textContent = 'Edit'; editButton.addEventListener('click', () => showEditForm(article, item)); const deleteButton = document.createElement('button'); deleteButton.type = 'button'; deleteButton.className = 'delete-button'; deleteButton.textContent = 'Delete'; deleteButton.addEventListener('click', () => deleteEntry(item, deleteButton)); const mastodonButton = document.createElement('button'); mastodonButton.type = 'button'; mastodonButton.className = `mastodon-button${item.mastodon_posted ? ' posted' : ''}`; const mastodonLogo = document.createElement('img'); mastodonLogo.src = '/static/mastodon.svg'; mastodonLogo.alt = ''; mastodonButton.append(mastodonLogo, document.createTextNode(item.mastodon_posted ? 'Post again' : 'Post to Mastodon')); mastodonButton.addEventListener('click', () => postToMastodon(item, mastodonButton)); actions.append(editButton, deleteButton); article.appendChild(actions); article.appendChild(mastodonButton); } if (showIdentity) { const header = document.createElement('div'); header.className = 'link-header'; const username = item.user?.username || 'unknown'; const profileLink = document.createElement('a'); profileLink.className = 'user-link'; profileLink.href = `/${encodeURIComponent(username)}/`; profileLink.setAttribute('aria-label', `View ${username}'s profile`); profileLink.appendChild(createAvatar(item.user)); const userName = document.createElement('div'); userName.className = 'user-name'; userName.textContent = username; profileLink.appendChild(userName); header.appendChild(profileLink); article.appendChild(header); } article.appendChild(title); article.appendChild(comment); article.appendChild(entryMeta); feedEl.appendChild(article); }); } async function postToMastodon(item, button) { button.disabled = true; const response = await fetch(`/api/links/${encodeURIComponent(item.id)}/mastodon`, { method: 'POST', headers: {Authorization: `Bearer ${accessToken}`}, }); if (response.ok) { await loadFeed(); } else { button.disabled = false; } } async function deleteEntry(item, button) { if (!window.confirm(`Delete this link?`)) return; button.disabled = true; const response = await fetch(`/api/links/${encodeURIComponent(item.id)}`, { method: 'DELETE', headers: {Authorization: `Bearer ${accessToken}`}, }); if (response.ok) { await loadFeed(); } else { button.disabled = false; } } function showEditForm(article, item) { if (article.querySelector('.edit-form')) return; const form = document.createElement('form'); form.className = 'edit-form'; form.innerHTML = `
Tags
`; form.elements.title.value = item.title || ''; form.elements.url.value = item.url || ''; form.elements.comment.value = item.comment || ''; const tagOptions = form.querySelector('.edit-tag-options'); tagOptions.replaceChildren(...availableTags.map((tag) => { const label = document.createElement('label'); const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.value = tag; checkbox.checked = item.tags?.includes(tag) || false; label.append(checkbox, document.createTextNode(` ${tag}`)); return label; })); form.addEventListener('submit', async (event) => { event.preventDefault(); const selectedTags = [...tagOptions.querySelectorAll('input:checked')].map((input) => input.value); const newTags = form.elements.new_tags.value .split(',') .map((tag) => tag.trim()) .filter(Boolean) .map((tag) => tag.startsWith('#') ? tag : `#${tag}`); const tags = [...new Set([...selectedTags, ...newTags])]; if (tags.length > 10) { form.querySelector('.edit-tag-status').textContent = 'A link can have at most 10 tags.'; return; } const response = await fetch(`/api/links/${encodeURIComponent(item.id)}`, { method: 'PUT', headers: {'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`}, body: JSON.stringify({ title: form.elements.title.value, url: form.elements.url.value, comment: form.elements.comment.value, tags, }), }); if (response.ok) loadFeed(); }); article.appendChild(form); } async function loadFeed(selectedTag = null) { const routeUser = document.body.dataset.userFilter; const endpoint = routeUser ? `/api/public/feed/${encodeURIComponent(routeUser)}` : '/api/public/feed'; const response = await fetch(endpoint, { 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); } function syncPreferences() { const pref = readPreferences(); sortSelect.value = pref.sort; userFilter.value = pref.user; tagFilter.value = pref.tag || ''; searchInput.value = pref.search || ''; sortSelect.addEventListener('change', (event) => { const next = { ...readPreferences(), sort: event.target.value }; writePreferences(next); loadFeed(); }); userFilter.addEventListener('change', (event) => { const selectedUser = event.target.value.trim(); const next = { ...readPreferences(), user: selectedUser }; writePreferences(next); window.location.assign(selectedUser ? `/${encodeURIComponent(selectedUser)}/` : '/'); }); tagFilter.addEventListener('change', (event) => { const next = { ...readPreferences(), tag: event.target.value }; writePreferences(next); loadFeed(event.target.value); }); searchInput.addEventListener('input', (event) => { const next = { ...readPreferences(), search: event.target.value }; writePreferences(next); loadFeed(); }); } syncPreferences(); Promise.all([loadUsers(), loadTags()]).then(() => loadFeed()).catch(() => loadFeed());