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:
soup 2026-01-16 21:14:23 -05:00
commit fc625fb9cf
Signed by: soup
SSH key fingerprint: SHA256:GYxje8eQkJ6HZKzVWDdyOUF1TyDiprruGhE0Ym8qYDY
486 changed files with 195373 additions and 0 deletions

4
internal/static/css/pico.min.css vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,331 @@
/* Commit Mono */
@font-face {
font-family: "CommitMono";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("../fonts/CommitMono-400-Regular.woff2") format("woff2");
}
@font-face {
font-family: "CommitMono";
font-style: italic;
font-weight: 400;
font-display: swap;
src: url("../fonts/CommitMono-400-Italic.woff2") format("woff2");
}
@font-face {
font-family: "CommitMono-Light";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("../fonts/CommitMono-450-Regular.woff2") format("woff2");
}
@font-face {
font-family: "CommitMono-Light";
font-style: italic;
font-weight: 400;
font-display: swap;
src: url("../fonts/CommitMono-450-Italic.woff2") format("woff2");
}
@font-face {
font-family: "CommitMono";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("../fonts/CommitMono-700-Regular.woff2") format("woff2");
}
@font-face {
font-family: "CommitMono";
font-style: italic;
font-weight: 700;
font-display: swap;
src: url("../fonts/CommitMono-700-Italic.woff2") format("woff2");
}
@font-face {
font-family: "CommitMono-Light";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("../fonts/CommitMono-700-Regular.woff2") format("woff2");
}
@font-face {
font-family: "CommitMono-Light";
font-style: italic;
font-weight: 700;
font-display: swap;
src: url("../fonts/CommitMono-700-Italic.woff2") format("woff2");
}
:root {
color-scheme: light dark;
font-family:
"CommitMono-Light",
"CommitMono",
monospace;
font-size: 13px;
}
@media (prefers-color-scheme: dark) {
:root {
font-family:
"CommitMono",
monospace;
}
}
body {
margin: 0;
background: var(--pico-background-color);
color: var(--pico-color);
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
}
.site-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.5rem 2rem;
border-bottom: 2px solid currentColor;
}
.site-header .title {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.site-nav {
display: flex;
gap: 0.75rem;
align-items: center;
}
.site-nav a,
.site-nav button {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.08em;
border: 1px solid currentColor;
padding: 0.35rem 0.75rem;
background: transparent;
color: inherit;
}
.site-nav button.ghost {
background: transparent;
}
button.ghost {
border: 1px solid currentColor;
padding: 0.35rem 0.75rem;
background: transparent;
color: inherit;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1rem;
margin-bottom: 2rem;
}
.hero form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.5rem;
}
.hero input[type="url"] {
border-radius: 0;
border: 2px solid currentColor;
background: transparent;
padding: 0.6rem 0.75rem;
}
.hero button {
border-radius: 0;
border: 2px solid currentColor;
background: currentColor;
color: var(--pico-background-color);
padding: 0.6rem 1rem;
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 2rem;
}
.filter-pill {
border: 1px solid currentColor;
padding: 0.35rem 0.6rem;
font-size: 0.8rem;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.filter-pill.active {
background: currentColor;
color: var(--pico-background-color);
}
.gallery {
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
.card {
position: relative;
border: 2px solid currentColor;
background: transparent;
overflow: hidden;
min-height: 160px;
}
.card img,
.card video {
display: block;
width: 100%;
height: auto;
}
.card a {
color: inherit;
text-decoration: none;
}
.card .placeholder {
padding: 1rem;
font-size: 0.9rem;
}
.card .overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.75);
color: #fff;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 0.5rem;
padding: 0.8rem;
opacity: 0;
transition: opacity 0.15s ease;
}
.card:hover .overlay {
opacity: 1;
}
.card .overlay .title {
font-weight: 600;
font-size: 0.85rem;
}
.card .overlay .tags {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.detail {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
gap: 2rem;
}
.detail img {
width: 100%;
border: 2px solid currentColor;
}
.detail .meta {
display: grid;
gap: 0.75rem;
}
.detail .meta h1 {
margin: 0;
font-size: 1.5rem;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tag-chip {
border: 1px solid currentColor;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.tag-editor {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.tag-editor input {
border-radius: 0;
border: 2px solid currentColor;
padding: 0.45rem 0.6rem;
background: transparent;
}
.tag-suggestions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tag-suggestions button {
border-radius: 0;
border: 1px solid currentColor;
padding: 0.25rem 0.5rem;
background: transparent;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.actions button,
.actions form button {
border-radius: 0;
border: 1px solid currentColor;
padding: 0.45rem 0.8rem;
background: transparent;
}
.notice {
border: 2px dashed currentColor;
padding: 1rem;
font-size: 0.85rem;
}
@media (max-width: 900px) {
.detail {
grid-template-columns: 1fr;
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,39 @@
Copyright (c) 2023 Eigil Nikolajsen (eigi0088@gmail.com)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole

181
internal/static/js/app.js Normal file
View 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();
})();

42
internal/static/static.go Normal file
View file

@ -0,0 +1,42 @@
package static
import (
"crypto/sha256"
"embed"
"encoding/hex"
"net/http"
"strings"
"time"
)
//go:embed css/* js/* fonts/*
var staticFS embed.FS
// Version is set via -ldflags in production
var Version string
func init() {
if Version == "" {
h := sha256.Sum256([]byte(time.Now().String()))
Version = hex.EncodeToString(h[:4])
}
}
func VersionedPath(path string) string {
return "/static/" + Version + "/" + path
}
func Handler() http.Handler {
fileServer := http.FileServer(http.FS(staticFS))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
path = strings.TrimPrefix(path, "/static/")
if idx := strings.Index(path, "/"); idx != -1 {
path = path[idx+1:]
}
r.URL.Path = "/" + path
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
fileServer.ServeHTTP(w, r)
})
}