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
4
internal/static/css/pico.min.css
vendored
Normal file
4
internal/static/css/pico.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
331
internal/static/css/styles.css
Normal file
331
internal/static/css/styles.css
Normal 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;
|
||||
}
|
||||
}
|
||||
BIN
internal/static/fonts/CommitMono-400-Italic.woff2
Normal file
BIN
internal/static/fonts/CommitMono-400-Italic.woff2
Normal file
Binary file not shown.
BIN
internal/static/fonts/CommitMono-400-Regular.woff2
Normal file
BIN
internal/static/fonts/CommitMono-400-Regular.woff2
Normal file
Binary file not shown.
BIN
internal/static/fonts/CommitMono-450-Italic.woff2
Normal file
BIN
internal/static/fonts/CommitMono-450-Italic.woff2
Normal file
Binary file not shown.
BIN
internal/static/fonts/CommitMono-450-Regular.woff2
Normal file
BIN
internal/static/fonts/CommitMono-450-Regular.woff2
Normal file
Binary file not shown.
BIN
internal/static/fonts/CommitMono-700-Italic.woff2
Normal file
BIN
internal/static/fonts/CommitMono-700-Italic.woff2
Normal file
Binary file not shown.
BIN
internal/static/fonts/CommitMono-700-Regular.woff2
Normal file
BIN
internal/static/fonts/CommitMono-700-Regular.woff2
Normal file
Binary file not shown.
39
internal/static/fonts/OFL.txt
Normal file
39
internal/static/fonts/OFL.txt
Normal 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
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();
|
||||
})();
|
||||
42
internal/static/static.go
Normal file
42
internal/static/static.go
Normal 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)
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue