lookbook/internal/handlers/home.go
soup 2887d9c430
Add Twitter video support and use sql.Null[T] for nullable columns
- Store Twitter video URLs in embed_video_url column instead of downloading
- Play videos directly from Twitter's CDN in <video> element
- Fix Twitter API parsing (content_type/url fields)
- Strip t.co URLs from tweet text descriptions
- Use sql.Null[T] generic type for nullable DB columns
- Add Nullable[T] and Ptr[T] helper functions
- Add play indicator overlay for video items in grid
- Add migration for embed_video_url column
2026-01-17 01:23:32 -05:00

225 lines
6.1 KiB
Go

package handlers
import (
"context"
"net/http"
"strings"
"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
HasVideo bool
}
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">
{{if or .HasVideo (eq .ItemType "video")}}<div class="play-indicator">▶</div>{{end}}
{{else if .MediaID}}
<img src="/media/{{.MediaID}}" alt="{{if .Title}}{{.Title}}{{else}}Image{{end}}" loading="lazy">
{{if or .HasVideo (eq .ItemType "video")}}<div class="play-indicator">▶</div>{{end}}
{{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: item.Ptr(it.Title),
Description: item.Ptr(it.Description),
LinkURL: item.Ptr(it.LinkURL),
ItemType: it.ItemType,
EmbedHTML: item.Ptr(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
if strings.HasPrefix(m.ContentType, "video/") {
hi.HasVideo = true
}
}
}
// Also check for embed video URL
if it.EmbedVideoURL.Valid && it.EmbedVideoURL.V != "" {
hi.HasVideo = true
}
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)
}