287 lines
9.4 KiB
JavaScript
287 lines
9.4 KiB
JavaScript
// Copyright © 2026 Olaf Kolkman
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
(() => {
|
|
const entryForm = document.getElementById('entry-form');
|
|
const authRequired = document.getElementById('auth-required');
|
|
const urlInput = document.getElementById('url-input');
|
|
const titleInput = document.getElementById('title-input');
|
|
const commentInput = document.getElementById('comment-input');
|
|
const newTagsInput = document.getElementById('new-tags-input');
|
|
const existingTagsEl = document.getElementById('existing-tags');
|
|
const mastodonPublishing = document.getElementById('mastodon-publishing');
|
|
const mastodonEnabledCheckbox = document.getElementById('mastodon-enabled');
|
|
const scrapeStatus = document.getElementById('scrape-status');
|
|
const duplicateStatus = document.getElementById('duplicate-status');
|
|
const refetchTitleButton = document.getElementById('refetch-title-button');
|
|
const submitButton = document.getElementById('submit-button');
|
|
const submitStatus = document.getElementById('submit-status');
|
|
|
|
const token = localStorage.getItem('linklogAccessToken');
|
|
let availableTags = [];
|
|
let selectedTags = new Set();
|
|
let currentUser = null;
|
|
|
|
// Utility function to add status messages; splits on \n into real <br> line breaks without using innerHTML.
|
|
function setStatus(statusEl, message, isError = false) {
|
|
const lines = message.split('\n');
|
|
statusEl.replaceChildren(
|
|
...lines.flatMap((line, index) => (
|
|
index === 0 ? [document.createTextNode(line)] : [document.createElement('br'), document.createTextNode(line)]
|
|
)),
|
|
);
|
|
statusEl.className = `status ${isError ? 'error' : 'success'}`;
|
|
statusEl.classList.remove('hidden');
|
|
if (!isError) {
|
|
setTimeout(() => statusEl.classList.add('hidden'), 4000);
|
|
}
|
|
}
|
|
|
|
// Check authentication
|
|
if (!token) {
|
|
authRequired.classList.remove('hidden');
|
|
entryForm.classList.add('hidden');
|
|
return;
|
|
}
|
|
|
|
// Verify token is still valid
|
|
fetch('/api/auth/me', { headers: { Authorization: `Bearer ${token}` } })
|
|
.then((response) => {
|
|
if (!response.ok) throw new Error('Not authenticated');
|
|
return response.json();
|
|
})
|
|
.then((user) => {
|
|
currentUser = user;
|
|
entryForm.classList.remove('hidden');
|
|
Promise.all([loadTags(), loadMastodonPublishing()]);
|
|
})
|
|
.catch(() => {
|
|
localStorage.removeItem('linklogAccessToken');
|
|
authRequired.classList.remove('hidden');
|
|
entryForm.classList.add('hidden');
|
|
});
|
|
|
|
// Load available tags
|
|
async function loadTags() {
|
|
try {
|
|
const response = await fetch('/api/tags');
|
|
if (!response.ok) throw new Error('Could not load tags');
|
|
availableTags = await response.json();
|
|
renderTags();
|
|
} catch (error) {
|
|
console.error('Error loading tags:', error);
|
|
}
|
|
}
|
|
|
|
async function loadMastodonPublishing() {
|
|
try {
|
|
const response = await fetch('/api/user/plugins/mastodon', {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
if (!response.ok) return;
|
|
const config = await response.json();
|
|
mastodonEnabledCheckbox.checked = Boolean(config.configured);
|
|
mastodonPublishing.classList.toggle('hidden', !config.configured);
|
|
} catch (error) {
|
|
console.error('Could not load Mastodon configuration:', error);
|
|
}
|
|
}
|
|
|
|
// Render tag checkboxes
|
|
function renderTags() {
|
|
existingTagsEl.innerHTML = '';
|
|
availableTags.forEach((tag) => {
|
|
const label = document.createElement('label');
|
|
label.className = 'tag-checkbox';
|
|
|
|
const checkbox = document.createElement('input');
|
|
checkbox.type = 'checkbox';
|
|
checkbox.value = tag;
|
|
checkbox.checked = selectedTags.has(tag);
|
|
checkbox.addEventListener('change', () => {
|
|
if (checkbox.checked) {
|
|
selectedTags.add(tag);
|
|
} else {
|
|
selectedTags.delete(tag);
|
|
}
|
|
});
|
|
|
|
label.appendChild(checkbox);
|
|
label.append(` ${tag}`);
|
|
existingTagsEl.appendChild(label);
|
|
});
|
|
}
|
|
|
|
// Strip known tracking parameters so duplicate detection and scraping ignore them, mirroring the browser extension.
|
|
function removeKnownTrackingParams(urlString) {
|
|
try {
|
|
const url = new URL(urlString);
|
|
const known = new Set([
|
|
'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',
|
|
'utm_id', 'utm_name', 'gclid', 'fbclid', 'dclid', 'msclkid',
|
|
]);
|
|
for (const key of known) {
|
|
url.searchParams.delete(key);
|
|
}
|
|
return url.toString();
|
|
} catch (error) {
|
|
return urlString;
|
|
}
|
|
}
|
|
|
|
// Fetch the page title for the given URL and fill it in, unless the user already typed one.
|
|
let lastScrapedUrl = null;
|
|
async function fetchTitle(url, { force = false } = {}) {
|
|
if (!url || (!force && (titleInput.value.trim() || url === lastScrapedUrl))) return;
|
|
|
|
setStatus(scrapeStatus, 'Looking up title...', false);
|
|
|
|
try {
|
|
const response = await fetch(`/api/scrape?url=${encodeURIComponent(url)}`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 401) {
|
|
localStorage.removeItem('linklogAccessToken');
|
|
location.reload();
|
|
return;
|
|
}
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
|
|
lastScrapedUrl = url;
|
|
const data = await response.json();
|
|
if (data.title) {
|
|
titleInput.value = data.title;
|
|
setStatus(scrapeStatus, 'Title loaded!', false);
|
|
} else {
|
|
setStatus(scrapeStatus, 'No title found', true);
|
|
}
|
|
} catch (error) {
|
|
console.error('Scrape error:', error);
|
|
setStatus(scrapeStatus, `Error: ${error.message}`, true);
|
|
}
|
|
}
|
|
|
|
urlInput.addEventListener('blur', async () => {
|
|
await fetchTitle(removeKnownTrackingParams(urlInput.value.trim()));
|
|
checkDuplicate();
|
|
});
|
|
|
|
refetchTitleButton.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
const url = removeKnownTrackingParams(urlInput.value.trim());
|
|
if (!url) {
|
|
setStatus(scrapeStatus, 'Please enter a URL', true);
|
|
return;
|
|
}
|
|
titleInput.value = '';
|
|
fetchTitle(url, { force: true });
|
|
});
|
|
|
|
// Warn when the URL/title combination already exists for this user, mirroring the browser extension.
|
|
titleInput.addEventListener('blur', checkDuplicate);
|
|
|
|
urlInput.addEventListener('input', () => duplicateStatus.classList.add('hidden'));
|
|
titleInput.addEventListener('input', () => duplicateStatus.classList.add('hidden'));
|
|
|
|
async function checkDuplicate() {
|
|
const url = removeKnownTrackingParams(urlInput.value.trim());
|
|
const title = titleInput.value.trim();
|
|
if (!url || !title) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/links/check?${new URLSearchParams({ title, url })}`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
if (!response.ok) return;
|
|
const data = await response.json();
|
|
if (data.exists) {
|
|
let message = 'This link already exists. ';
|
|
if (!data.url_matches) {
|
|
message += 'But, the stored link has a different URL (missing or different parameters).';
|
|
message += '\nWhen you save the entry you risk a duplicate entry.';
|
|
} else {
|
|
message += '\nYou can still save the entry, which will update the existing entry\'s comment and or tags.';
|
|
}
|
|
setStatus(duplicateStatus, message, true);
|
|
} else {
|
|
duplicateStatus.classList.add('hidden');
|
|
}
|
|
} catch (error) {
|
|
// Duplicate checking is advisory; submission remains available.
|
|
}
|
|
}
|
|
|
|
// Handle form submission
|
|
entryForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
|
|
const url = removeKnownTrackingParams(urlInput.value.trim());
|
|
const title = titleInput.value.trim();
|
|
const comment = commentInput.value.trim();
|
|
const newTags = newTagsInput.value.trim();
|
|
|
|
if (!url || !title) {
|
|
setStatus(submitStatus, 'URL and title are required', true);
|
|
return;
|
|
}
|
|
|
|
// Combine selected tags and new tags
|
|
const tags = Array.from(selectedTags);
|
|
if (newTags) {
|
|
const newTagsList = newTags
|
|
.split(',')
|
|
.map((t) => t.trim())
|
|
.filter((t) => t);
|
|
tags.push(...newTagsList);
|
|
}
|
|
|
|
submitButton.disabled = true;
|
|
setStatus(submitStatus, 'Saving...', false);
|
|
|
|
try {
|
|
const response = await fetch('/api/links', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify({
|
|
title,
|
|
url,
|
|
comment,
|
|
tags,
|
|
post_to_mastodon: mastodonEnabledCheckbox.checked,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 401) {
|
|
localStorage.removeItem('linklogAccessToken');
|
|
location.reload();
|
|
return;
|
|
}
|
|
const error = await response.json().catch(() => ({}));
|
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
setStatus(submitStatus, `Link saved to LinkLog${data.duplicate ? ' (updated)' : ''}!`, false);
|
|
|
|
// Redirect to user page after 1 second
|
|
if (currentUser) {
|
|
setTimeout(() => {
|
|
window.location.href = `/${encodeURIComponent(currentUser.username)}/`;
|
|
}, 1000);
|
|
}
|
|
} catch (error) {
|
|
console.error('Submit error:', error);
|
|
setStatus(submitStatus, `Error: ${error.message}`, true);
|
|
} finally {
|
|
submitButton.disabled = false;
|
|
}
|
|
});
|
|
})();
|