Files
Link-Log/frontend/static/feed.js
T
2026-08-25 08:39:46 +02:00

260 lines
8.6 KiB
JavaScript

// 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 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 = '<div class="link-item"><p>No links yet.</p></div>';
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 = `
<label>Title <input name="title" value=""></label>
<label>URL <input name="url" type="url" value=""></label>
<label>Comment <textarea name="comment"></textarea></label>
<fieldset class="edit-tags">
<legend>Tags</legend>
<div class="edit-tag-options"></div>
<input name="new_tags" type="text" placeholder="New tags, separated by commas">
<p class="edit-tag-status" role="alert"></p>
</fieldset>
<button type="submit">Save changes</button>
`;
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());