lookbook/internal/handlers/home.go
soup cdcc5b5293
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
2026-01-17 01:09:23 -05:00

213 lines
5.6 KiB
Go

package handlers
import (
"context"
"net/http"
"time"
"git.soup.land/soup/sxgo/ssr"
"lookbook/internal/components"
"lookbook/internal/data/item"
"lookbook/internal/data/media"
"lookbook/internal/data/tag"
)
type homeContent struct {
Items []homeItem
Tags []string
ActiveTag string
IsAdmin bool
}
type homeItem struct {
ID string
Title *string
Description *string
LinkURL *string
ItemType string
EmbedHTML *string
Tags []string
ThumbnailID *int64
MediaID *int64
}
func (h homeContent) Render(sw *ssr.Writer) error {
return sw.Tmpl(h, `
<div class="home">
{{if .IsAdmin}}
<div class="admin-bar">
<button class="btn" onclick="showAddModal()">+ ADD ITEM</button>
</div>
{{end}}
<div class="tags-bar">
<a href="/" class="tag {{if not .ActiveTag}}active{{end}}">ALL</a>
{{range .Tags}}
<a href="/?tag={{.}}" class="tag {{if eq $.ActiveTag .}}active{{end}}">{{.}}</a>
{{end}}
</div>
<div class="grid">
{{range .Items}}
<a href="/item/{{.ID}}" class="grid-item" data-type="{{.ItemType}}">
{{if eq .ItemType "quote"}}
<div class="quote-card">
<blockquote>{{.Description}}</blockquote>
{{if .Title}}<cite>— {{.Title}}</cite>{{end}}
</div>
{{else if .ThumbnailID}}
<img src="/media/{{.ThumbnailID}}" alt="{{if .Title}}{{.Title}}{{else}}Image{{end}}" loading="lazy">
{{else if .MediaID}}
<img src="/media/{{.MediaID}}" alt="{{if .Title}}{{.Title}}{{else}}Image{{end}}" loading="lazy">
{{else if eq .ItemType "embed"}}
<div class="embed-placeholder">
<span>▶</span>
</div>
{{else}}
<div class="link-card">
{{if .Title}}<div class="link-title">{{.Title}}</div>{{end}}
{{if .LinkURL}}<div class="link-url">{{.LinkURL}}</div>{{end}}
</div>
{{end}}
{{if .Tags}}
<div class="item-tags">
{{range .Tags}}<span class="item-tag">{{.}}</span>{{end}}
</div>
{{end}}
</a>
{{end}}
</div>
</div>
{{if .IsAdmin}}
<div id="add-modal" class="modal" onclick="if(event.target===this)hideAddModal()">
<div class="modal-content">
<div class="modal-header">
<h2>ADD ITEM</h2>
<button class="btn-close" onclick="hideAddModal()">&times;</button>
</div>
<div class="modal-tabs">
<button class="tab active" data-tab="link">LINK</button>
<button class="tab" data-tab="upload">UPLOAD</button>
<button class="tab" data-tab="quote">QUOTE</button>
</div>
<div class="modal-body">
<div id="tab-link" class="tab-content active">
<form id="link-form" onsubmit="return submitLink(event)">
<input type="url" name="url" placeholder="Paste URL..." required>
<div id="link-preview" class="preview"></div>
<input type="text" name="title" placeholder="Title (optional)">
<textarea name="description" placeholder="Description (optional)"></textarea>
<input type="text" name="tags" placeholder="Tags (comma-separated)">
<button type="submit" class="btn">ADD</button>
</form>
</div>
<div id="tab-upload" class="tab-content">
<form id="upload-form" onsubmit="return submitUpload(event)">
<input type="file" name="file" accept="image/*,video/*" required>
<input type="text" name="title" placeholder="Title (optional)">
<textarea name="description" placeholder="Description (optional)"></textarea>
<input type="text" name="tags" placeholder="Tags (comma-separated)">
<button type="submit" class="btn">UPLOAD</button>
</form>
</div>
<div id="tab-quote" class="tab-content">
<form id="quote-form" onsubmit="return submitQuote(event)">
<textarea name="text" placeholder="Quote text..." required rows="4"></textarea>
<input type="text" name="source" placeholder="Source / Attribution (optional)">
<input type="url" name="sourceUrl" placeholder="Source URL (optional)">
<input type="text" name="tags" placeholder="Tags (comma-separated)">
<button type="submit" class="btn">ADD QUOTE</button>
</form>
</div>
</div>
</div>
</div>
{{end}}
`)
}
// HandleHome handles GET /
func HandleHome(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
tagFilter := r.URL.Query().Get("tag")
var items []item.Row
var err error
if tagFilter != "" {
items, err = item.QListByTag(ctx, rc.DB, tagFilter)
} else {
items, err = item.QList(ctx, rc.DB)
}
if err != nil {
return err
}
// Get all tags
allTags, err := tag.QList(ctx, rc.DB)
if err != nil {
return err
}
tagNames := make([]string, len(allTags))
for i, t := range allTags {
tagNames[i] = t.Name
}
// Build home items
homeItems := make([]homeItem, 0, len(items))
for _, it := range items {
hi := homeItem{
ID: it.PubID,
Title: it.Title,
Description: it.Description,
LinkURL: it.LinkURL,
ItemType: it.ItemType,
EmbedHTML: it.EmbedHTML,
}
// Get tags
itemTags, err := tag.QTagsForItem(ctx, rc.DB, it.ID)
if err != nil {
return err
}
hi.Tags = make([]string, len(itemTags))
for i, t := range itemTags {
hi.Tags[i] = t.Name
}
// Get media
mediaList, err := media.QFindByItemID(ctx, rc.DB, it.ID)
if err != nil {
return err
}
for _, m := range mediaList {
if m.MediaType == "thumbnail" {
hi.ThumbnailID = &m.ID
} else if m.MediaType == "original" {
hi.MediaID = &m.ID
}
}
homeItems = append(homeItems, hi)
}
content := homeContent{
Items: homeItems,
Tags: tagNames,
ActiveTag: tagFilter,
IsAdmin: rc.IsAdmin,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
sw := ssr.NewWriter(w, rc.TmplCache)
page := components.Page{
Title: "",
IsAdmin: rc.IsAdmin,
Content: content,
}
return page.Render(sw)
}