Initial commit: Lookbook personal collection app

Pinterest-like app for saving images, videos, quotes, and embeds.

Features:
- Go backend with PostgreSQL, SSR templates
- Console-based admin auth (login/logout via browser console)
- Item types: images, videos (ffmpeg transcoding), quotes, embeds
- Media stored as BLOBs in PostgreSQL
- OpenGraph metadata extraction for links
- Embed detection for YouTube, Vimeo, Twitter/X
- Masonry grid layout, item detail pages
- Tag system with filtering
- Refresh metadata endpoint with change warnings
- Replace media endpoint for updating item images/videos
This commit is contained in:
soup 2026-01-17 01:09:23 -05:00
commit cdcc5b5293
Signed by: soup
SSH key fingerprint: SHA256:GYxje8eQkJ6HZKzVWDdyOUF1TyDiprruGhE0Ym8qYDY
45 changed files with 4634 additions and 0 deletions

View file

@ -0,0 +1,153 @@
package tag
import (
"context"
"database/sql"
)
type Row struct {
ID int64
Name string
}
// QFindOrCreate finds a tag by name or creates it if it doesn't exist.
func QFindOrCreate(ctx context.Context, db *sql.DB, name string) (Row, error) {
query := `
INSERT INTO tag (name)
VALUES ($1)
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name
RETURNING id, name
`
var row Row
err := db.QueryRowContext(ctx, query, name).Scan(&row.ID, &row.Name)
return row, err
}
// QFindByName finds a tag by name.
func QFindByName(ctx context.Context, db *sql.DB, name string) (*Row, error) {
query := `SELECT id, name FROM tag WHERE name = $1`
var row Row
err := db.QueryRowContext(ctx, query, name).Scan(&row.ID, &row.Name)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &row, nil
}
// QList returns all tags ordered by name.
func QList(ctx context.Context, db *sql.DB) ([]Row, error) {
query := `SELECT id, name FROM tag ORDER BY name ASC`
rows, err := db.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var tags []Row
for rows.Next() {
var row Row
if err := rows.Scan(&row.ID, &row.Name); err != nil {
return nil, err
}
tags = append(tags, row)
}
return tags, rows.Err()
}
// QSuggest returns tags matching a prefix.
func QSuggest(ctx context.Context, db *sql.DB, prefix string, limit int) ([]Row, error) {
query := `
SELECT id, name FROM tag
WHERE name ILIKE $1 || '%'
ORDER BY name ASC
LIMIT $2
`
rows, err := db.QueryContext(ctx, query, prefix, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var tags []Row
for rows.Next() {
var row Row
if err := rows.Scan(&row.ID, &row.Name); err != nil {
return nil, err
}
tags = append(tags, row)
}
return tags, rows.Err()
}
// QTagsForItem returns all tags for an item.
func QTagsForItem(ctx context.Context, db *sql.DB, itemID int64) ([]Row, error) {
query := `
SELECT t.id, t.name
FROM tag t
JOIN item_tag it ON t.id = it.tag_id
WHERE it.item_id = $1
ORDER BY t.name ASC
`
rows, err := db.QueryContext(ctx, query, itemID)
if err != nil {
return nil, err
}
defer rows.Close()
var tags []Row
for rows.Next() {
var row Row
if err := rows.Scan(&row.ID, &row.Name); err != nil {
return nil, err
}
tags = append(tags, row)
}
return tags, rows.Err()
}
// QAddTagToItem adds a tag to an item.
func QAddTagToItem(ctx context.Context, db *sql.DB, itemID, tagID int64) error {
query := `
INSERT INTO item_tag (item_id, tag_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
`
_, err := db.ExecContext(ctx, query, itemID, tagID)
return err
}
// QRemoveTagFromItem removes a tag from an item.
func QRemoveTagFromItem(ctx context.Context, db *sql.DB, itemID, tagID int64) error {
query := `DELETE FROM item_tag WHERE item_id = $1 AND tag_id = $2`
_, err := db.ExecContext(ctx, query, itemID, tagID)
return err
}
// QSetTagsForItem replaces all tags for an item with the given tag names.
func QSetTagsForItem(ctx context.Context, db *sql.DB, itemID int64, tagNames []string) error {
// Delete existing tags
_, err := db.ExecContext(ctx, `DELETE FROM item_tag WHERE item_id = $1`, itemID)
if err != nil {
return err
}
// Add new tags
for _, name := range tagNames {
tag, err := QFindOrCreate(ctx, db, name)
if err != nil {
return err
}
if err := QAddTagToItem(ctx, db, itemID, tag.ID); err != nil {
return err
}
}
return nil
}