388 lines
14 KiB
JavaScript
388 lines
14 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 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');
|
|
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 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');
|
|
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 = '<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 || '';
|
|
|
|
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);
|
|
});
|
|
}
|
|
|
|
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`, {
|
|
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 = `
|
|
<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 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(`${path}?${params.toString()}`, {
|
|
headers: accessToken ? {Authorization: `Bearer ${accessToken}`} : {},
|
|
});
|
|
const data = await response.json();
|
|
|
|
currentPage = data.page || 1;
|
|
renderFeed(data.items || [], !routeUser);
|
|
renderPagination(currentPage, data.total_pages || 1);
|
|
}
|
|
|
|
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);
|
|
currentPage = 1;
|
|
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);
|
|
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();
|
|
loadConfig()
|
|
.then(() => Promise.all([loadUsers(), loadTags()]))
|
|
.then(() => loadFeed())
|
|
.catch(() => loadFeed());
|