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
This commit is contained in:
commit
fc625fb9cf
486 changed files with 195373 additions and 0 deletions
181
internal/static/js/app.js
Normal file
181
internal/static/js/app.js
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
(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();
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue