Pinterest-style visual bookmarking app with: - URL metadata extraction (OG/Twitter meta, oEmbed fallback) - Image caching in Postgres with 480px thumbnails - Multi-tag filtering with Ctrl/Cmd for OR mode - Fuzzy tag suggestions and inline tag editing - Browser console auth() with first-use password setup - Brutalist UI with Commit Mono font and Pico CSS - Light/dark mode via browser preference
181 lines
5 KiB
JavaScript
181 lines
5 KiB
JavaScript
(function() {
|
|
const logoutButton = document.querySelector('.site-nav button[onclick="logout()"]');
|
|
const authStatusEl = document.querySelector('[data-auth-status]');
|
|
|
|
window.auth = async function() {
|
|
const password = window.prompt('Enter password');
|
|
if (!password) return;
|
|
const resp = await fetch('/auth/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ password })
|
|
});
|
|
if (resp.ok) {
|
|
await refreshAuth();
|
|
} else {
|
|
alert('Auth failed');
|
|
}
|
|
};
|
|
|
|
window.logout = async function() {
|
|
await fetch('/auth/logout', { method: 'POST' });
|
|
await refreshAuth();
|
|
};
|
|
|
|
async function refreshAuth() {
|
|
const resp = await fetch('/auth/status');
|
|
if (!resp.ok) return;
|
|
const data = await resp.json();
|
|
window.LOOKBOOK_AUTH = data.authenticated;
|
|
if (logoutButton) {
|
|
logoutButton.hidden = !data.authenticated;
|
|
}
|
|
if (authStatusEl) {
|
|
authStatusEl.textContent = data.authenticated ? 'Authed' : 'Read only';
|
|
}
|
|
const gated = document.querySelectorAll('[data-auth-required]');
|
|
gated.forEach((el) => {
|
|
el.toggleAttribute('hidden', !data.authenticated);
|
|
});
|
|
}
|
|
|
|
const tagInput = document.querySelector('[data-tag-input]');
|
|
const tagSuggestions = document.querySelector('[data-tag-suggestions]');
|
|
const tagForm = document.querySelector('[data-tag-form]');
|
|
const tagEditor = document.querySelector('[data-tag-editor]');
|
|
const tagToggle = document.querySelector('[data-tag-toggle]');
|
|
const tagList = document.querySelector('[data-tag-list]');
|
|
const tagData = tagSuggestions ? JSON.parse(tagSuggestions.getAttribute('data-tags') || '[]') : [];
|
|
|
|
if (tagToggle && tagEditor) {
|
|
tagToggle.addEventListener('click', () => {
|
|
const isHidden = tagEditor.hasAttribute('hidden');
|
|
tagEditor.toggleAttribute('hidden', !isHidden);
|
|
});
|
|
}
|
|
|
|
if (tagList && tagEditor) {
|
|
tagList.addEventListener('click', () => {
|
|
if (!window.LOOKBOOK_AUTH) return;
|
|
const isHidden = tagEditor.hasAttribute('hidden');
|
|
tagEditor.toggleAttribute('hidden', !isHidden);
|
|
});
|
|
}
|
|
|
|
if (tagSuggestions) {
|
|
if (tagInput) {
|
|
tagInput.addEventListener('input', () => renderSuggestions(tagInput.value));
|
|
renderSuggestions(tagInput.value);
|
|
} else {
|
|
renderSuggestions('');
|
|
}
|
|
}
|
|
|
|
function renderSuggestions(value) {
|
|
if (!tagSuggestions) return;
|
|
const query = (value || '').trim().toLowerCase();
|
|
tagSuggestions.innerHTML = '';
|
|
const matches = fuzzyMatchTags(query, tagData).slice(0, 10);
|
|
matches.forEach((tag) => {
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.textContent = tag;
|
|
button.addEventListener('click', () => {
|
|
if (tagInput) {
|
|
addTag(tag);
|
|
return;
|
|
}
|
|
const target = Array.from(filterButtons).find((btn) => btn.dataset.tagFilter === tag);
|
|
if (target) target.click();
|
|
});
|
|
tagSuggestions.appendChild(button);
|
|
});
|
|
}
|
|
|
|
function addTag(tag) {
|
|
if (!tagInput) return;
|
|
const tags = parseTags(tagInput.value);
|
|
if (!tags.includes(tag)) tags.push(tag);
|
|
tagInput.value = tags.join(', ');
|
|
renderSuggestions(tagInput.value);
|
|
}
|
|
|
|
function parseTags(value) {
|
|
return value
|
|
.split(',')
|
|
.map((tag) => tag.trim())
|
|
.filter((tag) => tag.length > 0);
|
|
}
|
|
|
|
function fuzzyMatchTags(query, tags) {
|
|
if (!query) return tags;
|
|
const scores = tags.map((tag) => ({ tag, score: fuzzyScore(tag, query) }))
|
|
.filter((entry) => entry.score > 0)
|
|
.sort((a, b) => b.score - a.score || a.tag.localeCompare(b.tag));
|
|
return scores.map((entry) => entry.tag);
|
|
}
|
|
|
|
function fuzzyScore(tag, query) {
|
|
let score = 0;
|
|
let ti = 0;
|
|
for (const qc of query) {
|
|
const idx = tag.indexOf(qc, ti);
|
|
if (idx === -1) return 0;
|
|
score += idx === ti ? 3 : 1;
|
|
ti = idx + 1;
|
|
}
|
|
return score;
|
|
}
|
|
|
|
const filterButtons = document.querySelectorAll('[data-tag-filter]');
|
|
const gridItems = document.querySelectorAll('[data-item-tags]');
|
|
const selectedTags = new Set();
|
|
|
|
filterButtons.forEach((button) => {
|
|
button.addEventListener('click', (event) => {
|
|
const tag = button.dataset.tagFilter;
|
|
const multi = event.ctrlKey || event.metaKey;
|
|
if (!multi) {
|
|
selectedTags.clear();
|
|
filterButtons.forEach((b) => b.classList.remove('active'));
|
|
}
|
|
if (selectedTags.has(tag)) {
|
|
selectedTags.delete(tag);
|
|
button.classList.remove('active');
|
|
} else {
|
|
selectedTags.add(tag);
|
|
button.classList.add('active');
|
|
}
|
|
applyFilters();
|
|
});
|
|
});
|
|
|
|
function applyFilters() {
|
|
const selected = Array.from(selectedTags);
|
|
gridItems.forEach((item) => {
|
|
const tags = (item.getAttribute('data-item-tags') || '').split(',').map((t) => t.trim()).filter(Boolean);
|
|
if (selected.length === 0) {
|
|
item.removeAttribute('hidden');
|
|
return;
|
|
}
|
|
const matches = selected.some((tag) => tags.includes(tag));
|
|
item.toggleAttribute('hidden', !matches);
|
|
});
|
|
}
|
|
|
|
if (tagForm) {
|
|
tagForm.addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
const formData = new FormData(tagForm);
|
|
const resp = await fetch(tagForm.action, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
if (resp.ok) {
|
|
window.location.reload();
|
|
}
|
|
});
|
|
}
|
|
|
|
refreshAuth();
|
|
})();
|