Improve Twitter/X link handling and text formatting

- 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
This commit is contained in:
soup 2026-01-18 00:03:30 -05:00
parent 8a046728ef
commit 4a2cb341fa
Signed by: soup
SSH key fingerprint: SHA256:GYxje8eQkJ6HZKzVWDdyOUF1TyDiprruGhE0Ym8qYDY
7 changed files with 249 additions and 137 deletions

View file

@ -20,8 +20,8 @@ const (
ProviderTwitter Provider = "twitter" ProviderTwitter Provider = "twitter"
) )
// VideoInfo contains information about an embedded video. // EmbedInfo contains information about an embed (video, image gallery, or text).
type VideoInfo struct { type EmbedInfo struct {
Provider Provider Provider Provider
VideoID string VideoID string
Title string Title string
@ -30,6 +30,7 @@ type VideoInfo struct {
ThumbnailURLs []string // All thumbnail URLs (for multi-image tweets) ThumbnailURLs []string // All thumbnail URLs (for multi-image tweets)
EmbedHTML string EmbedHTML string
VideoURL string // Direct video URL (for Twitter videos) VideoURL string // Direct video URL (for Twitter videos)
MediaType string // "video", "images", "text" - for Twitter only
} }
var ( var (
@ -40,7 +41,7 @@ var (
) )
// Detect checks if a URL is a YouTube, Vimeo, or Twitter/X post and returns its info. // Detect checks if a URL is a YouTube, Vimeo, or Twitter/X post and returns its info.
func Detect(ctx context.Context, targetURL string) (*VideoInfo, error) { func Detect(ctx context.Context, targetURL string) (*EmbedInfo, error) {
// Try YouTube // Try YouTube
if matches := youtubeRegex.FindStringSubmatch(targetURL); len(matches) > 1 { if matches := youtubeRegex.FindStringSubmatch(targetURL); len(matches) > 1 {
return fetchYouTube(ctx, matches[1]) return fetchYouTube(ctx, matches[1])
@ -59,7 +60,7 @@ func Detect(ctx context.Context, targetURL string) (*VideoInfo, error) {
return nil, nil // Not a recognized embed return nil, nil // Not a recognized embed
} }
func fetchYouTube(ctx context.Context, videoID string) (*VideoInfo, error) { func fetchYouTube(ctx context.Context, videoID string) (*EmbedInfo, error) {
// YouTube thumbnails are available without API // YouTube thumbnails are available without API
thumbnailURL := fmt.Sprintf("https://img.youtube.com/vi/%s/maxresdefault.jpg", videoID) thumbnailURL := fmt.Sprintf("https://img.youtube.com/vi/%s/maxresdefault.jpg", videoID)
@ -77,7 +78,7 @@ func fetchYouTube(ctx context.Context, videoID string) (*VideoInfo, error) {
videoID, videoID,
) )
return &VideoInfo{ return &EmbedInfo{
Provider: ProviderYouTube, Provider: ProviderYouTube,
VideoID: videoID, VideoID: videoID,
Title: title, Title: title,
@ -86,7 +87,7 @@ func fetchYouTube(ctx context.Context, videoID string) (*VideoInfo, error) {
}, nil }, nil
} }
func fetchVimeo(ctx context.Context, videoID string) (*VideoInfo, error) { func fetchVimeo(ctx context.Context, videoID string) (*EmbedInfo, error) {
oembedURL := fmt.Sprintf("https://vimeo.com/api/oembed.json?url=%s", oembedURL := fmt.Sprintf("https://vimeo.com/api/oembed.json?url=%s",
url.QueryEscape("https://vimeo.com/"+videoID)) url.QueryEscape("https://vimeo.com/"+videoID))
@ -100,7 +101,7 @@ func fetchVimeo(ctx context.Context, videoID string) (*VideoInfo, error) {
videoID, videoID,
) )
return &VideoInfo{ return &EmbedInfo{
Provider: ProviderVimeo, Provider: ProviderVimeo,
VideoID: videoID, VideoID: videoID,
Title: meta.Title, Title: meta.Title,
@ -110,7 +111,31 @@ func fetchVimeo(ctx context.Context, videoID string) (*VideoInfo, error) {
}, nil }, nil
} }
// twitterSyndicationResponse represents the Twitter syndication API response // fxTwitterResponse represents the FxTwitter API response
type fxTwitterResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Tweet struct {
Text string `json:"text"`
Author struct {
Name string `json:"name"`
ScreenName string `json:"screen_name"`
} `json:"author"`
Media struct {
Photos []struct {
URL string `json:"url"`
Width int `json:"width"`
Height int `json:"height"`
} `json:"photos"`
Videos []struct {
URL string `json:"url"`
} `json:"videos"`
} `json:"media"`
IsNoteTweet bool `json:"is_note_tweet"`
} `json:"tweet"`
}
// twitterSyndicationResponse represents the Twitter syndication API response (fallback)
type twitterSyndicationResponse struct { type twitterSyndicationResponse struct {
Text string `json:"text"` Text string `json:"text"`
User struct { User struct {
@ -138,7 +163,91 @@ type twitterSyndicationResponse struct {
} `json:"video"` } `json:"video"`
} }
func fetchTwitter(ctx context.Context, tweetID string, originalURL string) (*VideoInfo, error) { func fetchTwitter(ctx context.Context, tweetID string, originalURL string) (*EmbedInfo, error) {
// Extract username from URL for FxTwitter API
matches := twitterRegex.FindStringSubmatch(originalURL)
if len(matches) < 3 {
return nil, fmt.Errorf("invalid twitter URL format")
}
username := matches[1]
// Try FxTwitter API first (supports full note tweets)
fxURL := fmt.Sprintf("https://api.fxtwitter.com/%s/status/%s", username, tweetID)
req, err := http.NewRequestWithContext(ctx, "GET", fxURL, nil)
if err == nil {
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; Lookbook/1.0)")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err == nil {
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
var fxResp fxTwitterResponse
if err := json.NewDecoder(resp.Body).Decode(&fxResp); err == nil && fxResp.Code == 200 {
// Successfully got data from FxTwitter
return parseFxTwitterResponse(&fxResp, tweetID, originalURL)
}
}
}
}
// Fallback to syndication API (may have truncated text for note tweets)
return fetchTwitterSyndication(ctx, tweetID, originalURL)
}
func parseFxTwitterResponse(fxResp *fxTwitterResponse, tweetID string, originalURL string) (*EmbedInfo, error) {
tweet := &fxResp.Tweet
// Collect media
var thumbnailURL, videoURL string
var thumbnailURLs []string
var mediaType string
if len(tweet.Media.Videos) > 0 {
videoURL = tweet.Media.Videos[0].URL
mediaType = "video"
// Videos usually have a poster/thumbnail in photos
if len(tweet.Media.Photos) > 0 {
thumbnailURL = tweet.Media.Photos[0].URL
}
} else if len(tweet.Media.Photos) > 0 {
thumbnailURL = tweet.Media.Photos[0].URL
for _, photo := range tweet.Media.Photos {
thumbnailURLs = append(thumbnailURLs, photo.URL)
}
mediaType = "images"
} else {
mediaType = "text"
}
// Build embed HTML using Twitter's embed widget
embedHTML := fmt.Sprintf(
`<blockquote class="twitter-tweet"><a href="%s"></a></blockquote><script async src="https://platform.twitter.com/widgets.js"></script>`,
originalURL,
)
title := fmt.Sprintf("@%s", tweet.Author.ScreenName)
if tweet.Author.Name != "" {
title = fmt.Sprintf("%s (@%s)", tweet.Author.Name, tweet.Author.ScreenName)
}
// Clean up tweet text - remove trailing t.co URLs
description := tcoRegex.ReplaceAllString(tweet.Text, "")
description = strings.TrimSpace(description)
return &EmbedInfo{
Provider: ProviderTwitter,
VideoID: tweetID,
Title: title,
Description: description,
ThumbnailURL: thumbnailURL,
ThumbnailURLs: thumbnailURLs,
VideoURL: videoURL,
EmbedHTML: embedHTML,
MediaType: mediaType,
}, nil
}
func fetchTwitterSyndication(ctx context.Context, tweetID string, originalURL string) (*EmbedInfo, error) {
apiURL := fmt.Sprintf("https://cdn.syndication.twimg.com/tweet-result?id=%s&token=0", tweetID) apiURL := fmt.Sprintf("https://cdn.syndication.twimg.com/tweet-result?id=%s&token=0", tweetID)
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
@ -166,12 +275,15 @@ func fetchTwitter(ctx context.Context, tweetID string, originalURL string) (*Vid
// Find thumbnail and video URL from media // Find thumbnail and video URL from media
var thumbnailURL, videoURL string var thumbnailURL, videoURL string
var thumbnailURLs []string var thumbnailURLs []string
var mediaType string
if len(tweet.Photos) > 0 { if len(tweet.Photos) > 0 {
thumbnailURL = tweet.Photos[0].URL thumbnailURL = tweet.Photos[0].URL
// Collect all photo URLs for multi-image tweets // Collect all photo URLs for multi-image tweets
for _, photo := range tweet.Photos { for _, photo := range tweet.Photos {
thumbnailURLs = append(thumbnailURLs, photo.URL) thumbnailURLs = append(thumbnailURLs, photo.URL)
} }
mediaType = "images"
} else if len(tweet.MediaDetails) > 0 { } else if len(tweet.MediaDetails) > 0 {
media := tweet.MediaDetails[0] media := tweet.MediaDetails[0]
thumbnailURL = media.MediaURLHTTPS thumbnailURL = media.MediaURLHTTPS
@ -185,8 +297,12 @@ func fetchTwitter(ctx context.Context, tweetID string, originalURL string) (*Vid
videoURL = v.URL videoURL = v.URL
} }
} }
mediaType = "video"
} }
} else {
mediaType = "text"
} }
if thumbnailURL == "" && tweet.Video.Poster != "" { if thumbnailURL == "" && tweet.Video.Poster != "" {
thumbnailURL = tweet.Video.Poster thumbnailURL = tweet.Video.Poster
} }
@ -206,7 +322,7 @@ func fetchTwitter(ctx context.Context, tweetID string, originalURL string) (*Vid
description := tcoRegex.ReplaceAllString(tweet.Text, "") description := tcoRegex.ReplaceAllString(tweet.Text, "")
description = strings.TrimSpace(description) description = strings.TrimSpace(description)
return &VideoInfo{ return &EmbedInfo{
Provider: ProviderTwitter, Provider: ProviderTwitter,
VideoID: tweetID, VideoID: tweetID,
Title: title, Title: title,
@ -215,6 +331,7 @@ func fetchTwitter(ctx context.Context, tweetID string, originalURL string) (*Vid
ThumbnailURLs: thumbnailURLs, ThumbnailURLs: thumbnailURLs,
VideoURL: videoURL, VideoURL: videoURL,
EmbedHTML: embedHTML, EmbedHTML: embedHTML,
MediaType: mediaType,
}, nil }, nil
} }

View file

@ -12,18 +12,17 @@ import (
) )
type itemResponse struct { type itemResponse struct {
ID string `json:"id"` ID string `json:"id"`
Title *string `json:"title,omitempty"` Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
LinkURL *string `json:"linkUrl,omitempty"` LinkURL *string `json:"linkUrl,omitempty"`
ItemType string `json:"itemType"` ItemType string `json:"itemType"`
EmbedHTML *string `json:"embedHtml,omitempty"` EmbedHTML *string `json:"embedHtml,omitempty"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
MediaID *int64 `json:"mediaId,omitempty"` MediaID *int64 `json:"mediaId,omitempty"`
ThumbnailID *int64 `json:"thumbnailId,omitempty"` ImageIDs []int64 `json:"imageIds,omitempty"` // Fetched images (from URLs/embeds)
ThumbnailSourceURL *string `json:"thumbnailSourceUrl,omitempty"` ImageURLs []string `json:"imageUrls,omitempty"` // Source URLs for fetched images
GalleryIDs []int64 `json:"galleryIds,omitempty"` // Additional images (for multi-image tweets)
} }
type createItemRequest struct { type createItemRequest struct {
@ -252,22 +251,17 @@ func buildItemResponse(ctx context.Context, rc *RequestContext, it item.Row) (it
} }
// Get media IDs // Get media IDs
// Media is ordered by ID, so first "image" is the thumbnail, rest are gallery
mediaList, err := media.QFindByItemID(ctx, rc.DB, it.ID) mediaList, err := media.QFindByItemID(ctx, rc.DB, it.ID)
if err != nil { if err != nil {
return itemResponse{}, err return itemResponse{}, err
} }
firstImage := true
for _, m := range mediaList { for _, m := range mediaList {
if m.MediaType == "original" { if m.MediaType == "original" {
resp.MediaID = &m.ID resp.MediaID = &m.ID
} else if m.MediaType == "image" { } else if m.MediaType == "image" {
if firstImage { resp.ImageIDs = append(resp.ImageIDs, m.ID)
resp.ThumbnailID = &m.ID if m.SourceURL != nil {
resp.ThumbnailSourceURL = m.SourceURL resp.ImageURLs = append(resp.ImageURLs, *m.SourceURL)
firstImage = false
} else {
resp.GalleryIDs = append(resp.GalleryIDs, m.ID)
} }
} }
} }

View file

@ -96,22 +96,24 @@ type urlMetadata struct {
VideoID string VideoID string
EmbedHTML string EmbedHTML string
VideoURL string // Direct video URL (for Twitter) VideoURL string // Direct video URL (for Twitter)
MediaType string // "video", "images", "text" - for Twitter only
} }
func fetchURLMetadata(ctx context.Context, url string) (*urlMetadata, error) { func fetchURLMetadata(ctx context.Context, url string) (*urlMetadata, error) {
// Check if it's a YouTube/Vimeo/Twitter embed // Check if it's a YouTube/Vimeo/Twitter embed
videoInfo, err := embed.Detect(ctx, url) embedInfo, err := embed.Detect(ctx, url)
if err == nil && videoInfo != nil { if err == nil && embedInfo != nil {
return &urlMetadata{ return &urlMetadata{
Title: videoInfo.Title, Title: embedInfo.Title,
Description: videoInfo.Description, Description: embedInfo.Description,
ImageURL: videoInfo.ThumbnailURL, ImageURL: embedInfo.ThumbnailURL,
ImageURLs: videoInfo.ThumbnailURLs, ImageURLs: embedInfo.ThumbnailURLs,
IsEmbed: true, IsEmbed: true,
Provider: string(videoInfo.Provider), Provider: string(embedInfo.Provider),
VideoID: videoInfo.VideoID, VideoID: embedInfo.VideoID,
EmbedHTML: videoInfo.EmbedHTML, EmbedHTML: embedInfo.EmbedHTML,
VideoURL: videoInfo.VideoURL, VideoURL: embedInfo.VideoURL,
MediaType: embedInfo.MediaType,
}, nil }, nil
} }
@ -139,6 +141,7 @@ type previewResponse struct {
Provider string `json:"provider,omitempty"` Provider string `json:"provider,omitempty"`
VideoID string `json:"videoId,omitempty"` VideoID string `json:"videoId,omitempty"`
EmbedHTML string `json:"embedHtml,omitempty"` EmbedHTML string `json:"embedHtml,omitempty"`
MediaType string `json:"mediaType,omitempty"` // "video", "images", "text" - for Twitter
} }
// HandlePreviewLink handles POST /api/preview - fetches metadata for a URL // HandlePreviewLink handles POST /api/preview - fetches metadata for a URL
@ -175,6 +178,7 @@ func HandlePreviewLink(rc *RequestContext, w http.ResponseWriter, r *http.Reques
Provider: meta.Provider, Provider: meta.Provider,
VideoID: meta.VideoID, VideoID: meta.VideoID,
EmbedHTML: meta.EmbedHTML, EmbedHTML: meta.EmbedHTML,
MediaType: meta.MediaType,
}) })
} }
@ -207,35 +211,58 @@ func HandleCreateFromLink(rc *RequestContext, w http.ResponseWriter, r *http.Req
var itemType string var itemType string
var embedProvider, embedVideoID, embedHTML *string var embedProvider, embedVideoID, embedHTML *string
var imageURL, videoURL string
var imageURLs []string // For multi-image tweets
if req.Provider != nil && *req.Provider != "" { if req.Provider != nil && *req.Provider != "" {
// It's an embed // Special handling for Twitter based on media content
itemType = "embed" if *req.Provider == "twitter" {
embedProvider = req.Provider // Fetch tweet info to determine content type
embedVideoID = req.VideoID videoInfo, err := embed.Detect(ctx, req.URL)
embedHTML = req.EmbedHTML if err == nil && videoInfo != nil {
if videoInfo.VideoURL != "" {
// Tweet has video → keep as embed (proxied)
itemType = "embed"
embedProvider = req.Provider
embedVideoID = req.VideoID
embedHTML = req.EmbedHTML
videoURL = videoInfo.VideoURL
imageURL = videoInfo.ThumbnailURL
} else if len(videoInfo.ThumbnailURLs) > 0 {
// Tweet has image(s) → save as image
itemType = "image"
imageURL = videoInfo.ThumbnailURL
imageURLs = videoInfo.ThumbnailURLs
} else {
// Text-only tweet → save as quote
itemType = "quote"
// title and description already set from request
}
} else {
// If detection fails, fall back to link
itemType = "link"
}
} else {
// YouTube, Vimeo, etc. → keep as embed
itemType = "embed"
embedProvider = req.Provider
embedVideoID = req.VideoID
embedHTML = req.EmbedHTML
// Fetch thumbnail for non-Twitter embeds
if videoInfo, err := embed.Detect(ctx, req.URL); err == nil && videoInfo != nil {
imageURL = videoInfo.ThumbnailURL
videoURL = videoInfo.VideoURL
}
}
} else if req.ImageURL != nil && *req.ImageURL != "" { } else if req.ImageURL != nil && *req.ImageURL != "" {
// It's a link with an image // It's a link with an image
itemType = "image" itemType = "image"
imageURL = *req.ImageURL
} else { } else {
// Just a link (will be shown as a card) // Just a link (will be shown as a card)
itemType = "link" itemType = "link"
} }
// For embeds, fetch thumbnail(s) and video URL
var imageURL, videoURL string
var imageURLs []string // For multi-image tweets
if req.ImageURL != nil {
imageURL = *req.ImageURL
}
if itemType == "embed" && embedProvider != nil {
if videoInfo, err := embed.Detect(ctx, req.URL); err == nil && videoInfo != nil {
imageURL = videoInfo.ThumbnailURL
imageURLs = videoInfo.ThumbnailURLs
videoURL = videoInfo.VideoURL
}
}
// Create the item // Create the item
it, err := item.QCreate(ctx, rc.DB, item.CreateParams{ it, err := item.QCreate(ctx, rc.DB, item.CreateParams{
Title: item.Nullable(req.Title), Title: item.Nullable(req.Title),

View file

@ -28,11 +28,9 @@ type homeItem struct {
ItemType string ItemType string
EmbedHTML *string EmbedHTML *string
Tags []string Tags []string
ThumbnailID *int64
MediaID *int64 MediaID *int64
HasVideo bool HasVideo bool
GalleryIDs []int64 // Additional images for multi-image embeds ImageIDs []int64 // Fetched images (from URLs/embeds)
ImageCount int // Total image count (1 + len(GalleryIDs))
} }
func (h homeContent) Render(sw *ssr.Writer) error { func (h homeContent) Render(sw *ssr.Writer) error {
@ -54,33 +52,30 @@ func (h homeContent) Render(sw *ssr.Writer) error {
<div class="grid"> <div class="grid">
{{range .Items}} {{range .Items}}
<a href="/item/{{.ID}}" class="grid-item" data-type="{{.ItemType}}"> <a href="/item/{{.ID}}" class="grid-item" data-type="{{.ItemType}}">
{{if eq .ItemType "quote"}} {{if eq .ItemType "quote"}}
<div class="quote-card"> <div class="quote-card">
<blockquote>{{.Description}}</blockquote> <blockquote>{{.Description}}</blockquote>
{{if .Title}}<cite> {{.Title}}</cite>{{end}} {{if .Title}}<cite> {{.Title}}</cite>{{end}}
</div> </div>
{{else if .GalleryIDs}} {{else if .ImageIDs}}
<div class="grid-item-images" data-gallery="true" data-count="{{.ImageCount}}"> <div class="grid-item-images"{{if gt (len .ImageIDs) 1}} data-gallery="true"{{end}} data-count="{{len .ImageIDs}}">
<img src="/media/{{.ThumbnailID}}" alt="{{if .Title}}{{.Title}}{{else}}Image{{end}}" loading="lazy" class="active"> {{range $i, $id := .ImageIDs}}<img src="/media/{{$id}}" alt="Image" loading="lazy"{{if eq $i 0}} class="active"{{end}}>{{end}}
{{range .GalleryIDs}}<img src="/media/{{.}}" alt="Image" loading="lazy">{{end}} </div>
</div> {{if gt (len .ImageIDs) 1}}<div class="gallery-indicator">{{len .ImageIDs}}</div>{{end}}
<div class="gallery-indicator">{{.ImageCount}}</div> {{if .HasVideo}}<div class="play-indicator"></div>{{end}}
{{else if .ThumbnailID}} {{else if .MediaID}}
<img src="/media/{{.ThumbnailID}}" alt="{{if .Title}}{{.Title}}{{else}}Image{{end}}" loading="lazy"> <img src="/media/{{.MediaID}}" alt="Image" loading="lazy">
{{if or .HasVideo (eq .ItemType "video")}}<div class="play-indicator"></div>{{end}} {{if or .HasVideo (eq .ItemType "video")}}<div class="play-indicator"></div>{{end}}
{{else if .MediaID}} {{else if eq .ItemType "embed"}}
<img src="/media/{{.MediaID}}" alt="{{if .Title}}{{.Title}}{{else}}Image{{end}}" loading="lazy"> <div class="embed-placeholder">
{{if or .HasVideo (eq .ItemType "video")}}<div class="play-indicator"></div>{{end}} <span></span>
{{else if eq .ItemType "embed"}} </div>
<div class="embed-placeholder"> {{else}}
<span></span> <div class="link-card">
</div> {{if .Title}}<div class="link-title">{{.Title}}</div>{{end}}
{{else}} {{if .LinkURL}}<div class="link-url">{{.LinkURL}}</div>{{end}}
<div class="link-card"> </div>
{{if .Title}}<div class="link-title">{{.Title}}</div>{{end}} {{end}}
{{if .LinkURL}}<div class="link-url">{{.LinkURL}}</div>{{end}}
</div>
{{end}}
{{if or .Title .Tags}} {{if or .Title .Tags}}
<div class="item-overlay"> <div class="item-overlay">
{{if and .Title (ne .ItemType "link") (ne .ItemType "quote")}} {{if and .Title (ne .ItemType "link") (ne .ItemType "quote")}}
@ -197,12 +192,10 @@ func HandleHome(rc *RequestContext, w http.ResponseWriter, r *http.Request) erro
} }
// Get media // Get media
// Media is ordered by ID, so first "image" is the thumbnail, rest are gallery
mediaList, err := media.QFindByItemID(ctx, rc.DB, it.ID) mediaList, err := media.QFindByItemID(ctx, rc.DB, it.ID)
if err != nil { if err != nil {
return err return err
} }
firstImage := true
for _, m := range mediaList { for _, m := range mediaList {
if m.MediaType == "original" { if m.MediaType == "original" {
hi.MediaID = &m.ID hi.MediaID = &m.ID
@ -210,18 +203,9 @@ func HandleHome(rc *RequestContext, w http.ResponseWriter, r *http.Request) erro
hi.HasVideo = true hi.HasVideo = true
} }
} else if m.MediaType == "image" { } else if m.MediaType == "image" {
if firstImage { hi.ImageIDs = append(hi.ImageIDs, m.ID)
hi.ThumbnailID = &m.ID
firstImage = false
} else {
hi.GalleryIDs = append(hi.GalleryIDs, m.ID)
}
} }
} }
// Calculate total image count (thumbnail + gallery images)
if len(hi.GalleryIDs) > 0 {
hi.ImageCount = 1 + len(hi.GalleryIDs)
}
// Also check for embed video URL // Also check for embed video URL
if it.EmbedVideoURL.Valid && it.EmbedVideoURL.V != "" { if it.EmbedVideoURL.Valid && it.EmbedVideoURL.V != "" {

View file

@ -28,10 +28,9 @@ type itemPageData struct {
EmbedVideoURL *string EmbedVideoURL *string
Tags []string Tags []string
CreatedAt string CreatedAt string
ThumbnailID *int64
MediaID *int64 MediaID *int64
MediaIsVideo bool MediaIsVideo bool
GalleryIDs []int64 // Additional images for multi-image embeds ImageIDs []int64 // Fetched images (from URLs/embeds)
} }
func (c itemPageContent) Render(sw *ssr.Writer) error { func (c itemPageContent) Render(sw *ssr.Writer) error {
@ -51,37 +50,22 @@ func (c itemPageContent) Render(sw *ssr.Writer) error {
<source src="/media/{{.Item.MediaID}}" type="video/mp4"> <source src="/media/{{.Item.MediaID}}" type="video/mp4">
</video> </video>
</div> </div>
{{else if eq .Item.ItemType "embed"}} {{else if .Item.EmbedVideoURL}}
{{if .Item.EmbedVideoURL}}
<div class="video-container"> <div class="video-container">
<video loop muted playsinline poster="{{if .Item.ThumbnailID}}/media/{{.Item.ThumbnailID}}{{end}}"> <video loop muted playsinline {{if .Item.ImageIDs}}poster="/media/{{index .Item.ImageIDs 0}}"{{end}}>
<source src="/proxy/video/{{.Item.ID}}" type="video/mp4"> <source src="/proxy/video/{{.Item.ID}}" type="video/mp4">
</video> </video>
<div class="video-overlay" onclick="playVideo(this)"> <div class="video-overlay" onclick="playVideo(this)">
<span class="play-button">&#9654;</span> <span class="play-button">&#9654;</span>
</div> </div>
</div> </div>
{{else if .Item.GalleryIDs}} {{else if .Item.ImageIDs}}
<div class="image-gallery"> <div class="image-container{{if gt (len .Item.ImageIDs) 1}} image-gallery{{end}}">
{{if .Item.ThumbnailID}}<a href="/media/{{.Item.ThumbnailID}}"><img src="/media/{{.Item.ThumbnailID}}" alt="{{if .Item.Title}}{{.Item.Title}}{{else}}Image{{end}}"></a>{{end}} {{range .Item.ImageIDs}}<a href="/media/{{.}}"><img src="/media/{{.}}" alt="Image"></a>{{end}}
{{range .Item.GalleryIDs}}<a href="/media/{{.}}"><img src="/media/{{.}}" alt="Image"></a>{{end}}
</div>
{{else if .Item.ThumbnailID}}
<div class="image-container">
<a href="/media/{{.Item.ThumbnailID}}"><img src="/media/{{.Item.ThumbnailID}}" alt="{{if .Item.Title}}{{.Item.Title}}{{else}}Embed{{end}}"></a>
</div> </div>
{{else if .Item.MediaID}} {{else if .Item.MediaID}}
<div class="image-container"> <div class="image-container">
<a href="/media/{{.Item.MediaID}}"><img src="/media/{{.Item.MediaID}}" alt="{{if .Item.Title}}{{.Item.Title}}{{else}}Embed{{end}}"></a> <a href="/media/{{.Item.MediaID}}"><img src="/media/{{.Item.MediaID}}" alt="Image"></a>
</div>
{{end}}
{{else if .Item.MediaID}}
<div class="image-container">
<a href="/media/{{.Item.MediaID}}"><img src="/media/{{.Item.MediaID}}" alt="{{if .Item.Title}}{{.Item.Title}}{{else}}Image{{end}}"></a>
</div>
{{else if .Item.ThumbnailID}}
<div class="image-container">
<a href="/media/{{.Item.ThumbnailID}}"><img src="/media/{{.Item.ThumbnailID}}" alt="{{if .Item.Title}}{{.Item.Title}}{{else}}Image{{end}}"></a>
</div> </div>
{{end}} {{end}}
@ -179,26 +163,19 @@ func HandleItemPage(rc *RequestContext, w http.ResponseWriter, r *http.Request)
} }
// Get media // Get media
// Media is ordered by ID, so first "image" is the thumbnail, rest are gallery var mediaID *int64
var thumbnailID, mediaID *int64
var mediaIsVideo bool var mediaIsVideo bool
var galleryIDs []int64 var imageIDs []int64
mediaList, err := media.QFindByItemID(ctx, rc.DB, it.ID) mediaList, err := media.QFindByItemID(ctx, rc.DB, it.ID)
if err != nil { if err != nil {
return err return err
} }
firstImage := true
for _, m := range mediaList { for _, m := range mediaList {
if m.MediaType == "original" { if m.MediaType == "original" {
mediaID = &m.ID mediaID = &m.ID
mediaIsVideo = strings.HasPrefix(m.ContentType, "video/") mediaIsVideo = strings.HasPrefix(m.ContentType, "video/")
} else if m.MediaType == "image" { } else if m.MediaType == "image" {
if firstImage { imageIDs = append(imageIDs, m.ID)
thumbnailID = &m.ID
firstImage = false
} else {
galleryIDs = append(galleryIDs, m.ID)
}
} }
} }
@ -212,10 +189,9 @@ func HandleItemPage(rc *RequestContext, w http.ResponseWriter, r *http.Request)
EmbedVideoURL: item.Ptr(it.EmbedVideoURL), EmbedVideoURL: item.Ptr(it.EmbedVideoURL),
Tags: tagNames, Tags: tagNames,
CreatedAt: it.CreatedAt.Format("Jan 2, 2006"), CreatedAt: it.CreatedAt.Format("Jan 2, 2006"),
ThumbnailID: thumbnailID,
MediaID: mediaID, MediaID: mediaID,
MediaIsVideo: mediaIsVideo, MediaIsVideo: mediaIsVideo,
GalleryIDs: galleryIDs, ImageIDs: imageIDs,
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")

View file

@ -194,6 +194,7 @@ button, input, textarea, select {
font-style: italic; font-style: italic;
line-height: 1.6; line-height: 1.6;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
white-space: pre-wrap;
} }
.quote-card blockquote::before { .quote-card blockquote::before {
@ -376,6 +377,7 @@ button, input, textarea, select {
font-style: italic; font-style: italic;
line-height: 1.6; line-height: 1.6;
margin-bottom: 1rem; margin-bottom: 1rem;
white-space: pre-wrap;
} }
.quote-detail blockquote::before { .quote-detail blockquote::before {
@ -405,6 +407,7 @@ button, input, textarea, select {
.item-meta .description { .item-meta .description {
margin-bottom: 1rem; margin-bottom: 1rem;
color: var(--gray-3); color: var(--gray-3);
white-space: pre-wrap;
} }
.item-meta .source-link { .item-meta .source-link {
@ -597,6 +600,7 @@ input[type="file"] {
.preview-description { .preview-description {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--gray-3); color: var(--gray-3);
white-space: pre-wrap;
} }
/* Image Gallery (detail page) */ /* Image Gallery (detail page) */

View file

@ -183,7 +183,17 @@ async function fetchPreview(url) {
html += `<div class="preview-description">${escapeHtml(data.description)}</div>`; html += `<div class="preview-description">${escapeHtml(data.description)}</div>`;
} }
if (data.isEmbed) { if (data.isEmbed) {
html += `<div class="preview-badge">${escapeHtml(data.provider.toUpperCase())} VIDEO</div>`; let badge = data.provider.toUpperCase() + ' VIDEO';
if (data.provider === 'twitter') {
if (data.mediaType === 'video') {
badge = 'X VIDEO';
} else if (data.mediaType === 'images') {
badge = 'X GALLERY';
} else {
badge = 'X POST';
}
}
html += `<div class="preview-badge">${escapeHtml(badge)}</div>`;
} }
preview.innerHTML = html || "<div>No preview available</div>"; preview.innerHTML = html || "<div>No preview available</div>";