lookbook/internal/handlers/item_page.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

218 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 itemPageContent struct {
Item itemPageData
IsAdmin bool
}
type itemPageData struct {
ID string
Title *string
Description *string
LinkURL *string
ItemType string
EmbedHTML *string
EmbedVideoURL *string
Tags []string
CreatedAt string
ThumbnailID *int64
MediaID *int64
MediaIsVideo bool
}
func (c itemPageContent) Render(sw *ssr.Writer) error {
return sw.Tmpl(c, `
<div class="item-page">
<a href="/" class="back-link">&larr; BACK</a>
<article class="item-detail">
{{if eq .Item.ItemType "quote"}}
<div class="quote-detail">
<blockquote>{{.Item.Description}}</blockquote>
{{if .Item.Title}}<cite>— {{.Item.Title}}</cite>{{end}}
</div>
{{else if eq .Item.ItemType "video"}}
<div class="video-container">
<video controls>
<source src="/media/{{.Item.MediaID}}" type="video/mp4">
</video>
</div>
{{else if eq .Item.ItemType "embed"}}
{{if .Item.EmbedVideoURL}}
<div class="video-container">
<video controls poster="{{if .Item.ThumbnailID}}/media/{{.Item.ThumbnailID}}{{end}}">
<source src="{{.Item.EmbedVideoURL}}" type="video/mp4">
</video>
</div>
{{else if .Item.ThumbnailID}}
<div class="image-container">
<img src="/media/{{.Item.ThumbnailID}}" alt="{{if .Item.Title}}{{.Item.Title}}{{else}}Embed{{end}}">
</div>
{{else if .Item.MediaID}}
<div class="image-container">
<img src="/media/{{.Item.MediaID}}" alt="{{if .Item.Title}}{{.Item.Title}}{{else}}Embed{{end}}">
</div>
{{end}}
{{else if .Item.MediaID}}
<div class="image-container">
<img src="/media/{{.Item.MediaID}}" alt="{{if .Item.Title}}{{.Item.Title}}{{else}}Image{{end}}">
</div>
{{else if .Item.ThumbnailID}}
<div class="image-container">
<img src="/media/{{.Item.ThumbnailID}}" alt="{{if .Item.Title}}{{.Item.Title}}{{else}}Image{{end}}">
</div>
{{end}}
<div class="item-meta">
{{if .Item.Title}}
<h1>{{.Item.Title}}</h1>
{{end}}
{{if and .Item.Description (ne .Item.ItemType "quote")}}
<p class="description">{{.Item.Description}}</p>
{{end}}
{{if .Item.LinkURL}}
<a href="{{.Item.LinkURL}}" target="_blank" rel="noopener" class="source-link">{{.Item.LinkURL}}</a>
{{end}}
{{if .Item.Tags}}
<div class="item-tags">
{{range .Item.Tags}}
<a href="/?tag={{.}}" class="item-tag">{{.}}</a>
{{end}}
</div>
{{end}}
<time class="timestamp">{{.Item.CreatedAt}}</time>
</div>
{{if .IsAdmin}}
<div class="item-actions">
<button class="btn" onclick="editItem('{{.Item.ID}}')">EDIT</button>
{{if .Item.LinkURL}}<button class="btn" onclick="refreshMetadata('{{.Item.ID}}')">REFRESH</button>{{end}}
<button class="btn btn-danger" onclick="deleteItem('{{.Item.ID}}')">DELETE</button>
</div>
{{end}}
</article>
</div>
{{if .IsAdmin}}
<div id="edit-modal" class="modal" onclick="if(event.target===this)hideEditModal()">
<div class="modal-content">
<div class="modal-header">
<h2>EDIT ITEM</h2>
<button class="btn-close" onclick="hideEditModal()">&times;</button>
</div>
<div class="modal-body">
<form id="edit-form" onsubmit="return submitEdit(event)">
<input type="hidden" name="id" value="{{.Item.ID}}">
<input type="text" name="title" placeholder="Title" value="{{if .Item.Title}}{{.Item.Title}}{{end}}">
<textarea name="description" placeholder="Description">{{if .Item.Description}}{{.Item.Description}}{{end}}</textarea>
<input type="url" name="linkUrl" placeholder="Link URL" value="{{if .Item.LinkURL}}{{.Item.LinkURL}}{{end}}">
<input type="text" name="tags" placeholder="Tags (comma-separated)" value="{{range $i, $t := .Item.Tags}}{{if $i}}, {{end}}{{$t}}{{end}}">
<button type="submit" class="btn">SAVE</button>
</form>
<hr style="margin: 1.5rem 0; border: none; border-top: 1px solid var(--border);">
<form id="replace-media-form" onsubmit="return submitReplaceMedia(event, '{{.Item.ID}}')">
<label style="display: block; margin-bottom: 0.5rem; font-size: 0.875rem;">REPLACE IMAGE/VIDEO</label>
<input type="file" name="file" accept="image/*,video/*" required>
<button type="submit" class="btn" style="margin-top: 0.5rem;">REPLACE</button>
</form>
</div>
</div>
</div>
{{end}}
`)
}
// HandleItemPage handles GET /item/{id}
func HandleItemPage(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
pubID := r.PathValue("id")
if pubID == "" {
http.NotFound(w, r)
return nil
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
it, err := item.QFindByPubID(ctx, rc.DB, pubID)
if err != nil {
return err
}
if it == nil {
http.NotFound(w, r)
return nil
}
// Get tags
itemTags, err := tag.QTagsForItem(ctx, rc.DB, it.ID)
if err != nil {
return err
}
tagNames := make([]string, len(itemTags))
for i, t := range itemTags {
tagNames[i] = t.Name
}
// Get media
var thumbnailID, mediaID *int64
var mediaIsVideo bool
mediaList, err := media.QFindByItemID(ctx, rc.DB, it.ID)
if err != nil {
return err
}
for _, m := range mediaList {
if m.MediaType == "thumbnail" {
thumbnailID = &m.ID
} else if m.MediaType == "original" {
mediaID = &m.ID
mediaIsVideo = strings.HasPrefix(m.ContentType, "video/")
}
}
data := itemPageData{
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),
EmbedVideoURL: item.Ptr(it.EmbedVideoURL),
Tags: tagNames,
CreatedAt: it.CreatedAt.Format("Jan 2, 2006"),
ThumbnailID: thumbnailID,
MediaID: mediaID,
MediaIsVideo: mediaIsVideo,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
sw := ssr.NewWriter(w, rc.TmplCache)
var title string
if it.Title.Valid {
title = it.Title.V
}
page := components.Page{
Title: title,
IsAdmin: rc.IsAdmin,
Content: itemPageContent{Item: data, IsAdmin: rc.IsAdmin},
}
return page.Render(sw)
}