lookbook/internal/static/js/app.js
soup fc625fb9cf
Initial lookbook implementation
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
2026-01-16 21:14:23 -05:00

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();
})();