- Use FxTwitter API for full note tweet text (with syndication API fallback) - Save Twitter posts based on media content: - Videos → embed type (proxied video) - Images → image type (gallery) - Text-only → quote type - Add granular preview badges: 'X VIDEO', 'X GALLERY', 'X POST' - Preserve formatting/spacing with white-space: pre-wrap for quotes and descriptions - Rename VideoInfo to EmbedInfo for better semantic clarity
212 lines
6 KiB
Go
212 lines
6 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
|
|
MediaID *int64
|
|
MediaIsVideo bool
|
|
ImageIDs []int64 // Fetched images (from URLs/embeds)
|
|
}
|
|
|
|
func (c itemPageContent) Render(sw *ssr.Writer) error {
|
|
return sw.Tmpl(c, `
|
|
<div class="item-page">
|
|
<a href="/" class="back-link">← 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 .Item.EmbedVideoURL}}
|
|
<div class="video-container">
|
|
<video loop muted playsinline {{if .Item.ImageIDs}}poster="/media/{{index .Item.ImageIDs 0}}"{{end}}>
|
|
<source src="/proxy/video/{{.Item.ID}}" type="video/mp4">
|
|
</video>
|
|
<div class="video-overlay" onclick="playVideo(this)">
|
|
<span class="play-button">▶</span>
|
|
</div>
|
|
</div>
|
|
{{else if .Item.ImageIDs}}
|
|
<div class="image-container{{if gt (len .Item.ImageIDs) 1}} image-gallery{{end}}">
|
|
{{range .Item.ImageIDs}}<a href="/media/{{.}}"><img src="/media/{{.}}" alt="Image"></a>{{end}}
|
|
</div>
|
|
{{else if .Item.MediaID}}
|
|
<div class="image-container">
|
|
<a href="/media/{{.Item.MediaID}}"><img src="/media/{{.Item.MediaID}}" alt="Image"></a>
|
|
</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()">×</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 mediaID *int64
|
|
var mediaIsVideo bool
|
|
var imageIDs []int64
|
|
mediaList, err := media.QFindByItemID(ctx, rc.DB, it.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, m := range mediaList {
|
|
if m.MediaType == "original" {
|
|
mediaID = &m.ID
|
|
mediaIsVideo = strings.HasPrefix(m.ContentType, "video/")
|
|
} else if m.MediaType == "image" {
|
|
imageIDs = append(imageIDs, m.ID)
|
|
}
|
|
}
|
|
|
|
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"),
|
|
MediaID: mediaID,
|
|
MediaIsVideo: mediaIsVideo,
|
|
ImageIDs: imageIDs,
|
|
}
|
|
|
|
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)
|
|
}
|