const feedEl = document.getElementById('feed'); const sortSelect = document.getElementById('sort-select'); const userFilter = document.getElementById('user-filter'); const tagFilter = document.getElementById('tag-filter'); 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 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'); 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 || 'No comment provided'; if (item.tags?.length) { const tags = document.createElement('div'); tags.className = 'entry-tags'; tags.textContent = item.tags.join(' '); article.appendChild(tags); } const meta = document.createElement('div'); meta.className = 'meta'; meta.textContent = formatDate(item.created_at); if (item.is_owner) { const editButton = document.createElement('button'); editButton.type = 'button'; editButton.className = 'edit-button'; editButton.textContent = 'Edit'; editButton.addEventListener('click', () => showEditForm(article, item)); article.appendChild(editButton); } if (showIdentity) { const header = document.createElement('div'); header.className = 'link-header'; header.appendChild(createAvatar(item.user)); const userName = document.createElement('div'); userName.className = 'user-name'; userName.textContent = item.user?.username || 'unknown'; header.appendChild(userName); article.appendChild(header); } article.appendChild(title); article.appendChild(comment); article.appendChild(meta); feedEl.appendChild(article); }); } 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() { 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(); if (pref.user && !routeUser) { items = items.filter((item) => (item.user?.username || '').toLowerCase() === pref.user.toLowerCase()); } if (pref.tag) { items = items.filter((item) => item.tags?.some((tag) => tag.toLowerCase() === pref.tag.toLowerCase())); } 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 || ''; 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(); }); } syncPreferences(); Promise.all([loadUsers(), loadTags()]).then(loadFeed).catch(() => loadFeed());