- 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
235 lines
6.5 KiB
Go
235 lines
6.5 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
|
|
MediaID *int64
|
|
HasVideo bool
|
|
ImageIDs []int64 // Fetched images (from URLs/embeds)
|
|
}
|
|
|
|
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 .ImageIDs}}
|
|
<div class="grid-item-images"{{if gt (len .ImageIDs) 1}} data-gallery="true"{{end}} data-count="{{len .ImageIDs}}">
|
|
{{range $i, $id := .ImageIDs}}<img src="/media/{{$id}}" alt="Image" loading="lazy"{{if eq $i 0}} class="active"{{end}}>{{end}}
|
|
</div>
|
|
{{if gt (len .ImageIDs) 1}}<div class="gallery-indicator">{{len .ImageIDs}}</div>{{end}}
|
|
{{if .HasVideo}}<div class="play-indicator">▶</div>{{end}}
|
|
{{else if .MediaID}}
|
|
<img src="/media/{{.MediaID}}" alt="Image" 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 or .Title .Tags}}
|
|
<div class="item-overlay">
|
|
{{if and .Title (ne .ItemType "link") (ne .ItemType "quote")}}
|
|
<div class="item-title">{{.Title}}</div>
|
|
{{end}}
|
|
{{if .Tags}}
|
|
<div class="item-tags">
|
|
{{range .Tags}}<span class="item-tag">{{.}}</span>{{end}}
|
|
</div>
|
|
{{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()">×</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 == "original" {
|
|
hi.MediaID = &m.ID
|
|
if strings.HasPrefix(m.ContentType, "video/") {
|
|
hi.HasVideo = true
|
|
}
|
|
} else if m.MediaType == "image" {
|
|
hi.ImageIDs = append(hi.ImageIDs, m.ID)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|