Add support for multiple images from Twitter links

- Fetch all images from Twitter syndication API (photos array)
- Store images with unified 'image' media type (first = thumbnail by ID order)
- Display multi-image tweets in grid layout on detail page
- Add hover cycling through images on home grid (2s interval)
- Show image count indicator on multi-image items
- Extract shared downloadAndStoreImages() helper for create/refresh
- Add migration to convert existing thumbnail/gallery types to image
- Make images clickable to open in new tab on detail page
This commit is contained in:
soup 2026-01-17 13:28:20 -05:00
parent e917e67930
commit 007e167707
Signed by: soup
SSH key fingerprint: SHA256:GYxje8eQkJ6HZKzVWDdyOUF1TyDiprruGhE0Ym8qYDY
9 changed files with 625 additions and 403 deletions

View file

@ -23,6 +23,7 @@ type itemResponse struct {
MediaID *int64 `json:"mediaId,omitempty"`
ThumbnailID *int64 `json:"thumbnailId,omitempty"`
ThumbnailSourceURL *string `json:"thumbnailSourceUrl,omitempty"`
GalleryIDs []int64 `json:"galleryIds,omitempty"` // Additional images (for multi-image tweets)
}
type createItemRequest struct {
@ -251,16 +252,23 @@ func buildItemResponse(ctx context.Context, rc *RequestContext, it item.Row) (it
}
// 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)
if err != nil {
return itemResponse{}, err
}
firstImage := true
for _, m := range mediaList {
if m.MediaType == "original" {
resp.MediaID = &m.ID
} else if m.MediaType == "thumbnail" {
resp.ThumbnailID = &m.ID
resp.ThumbnailSourceURL = m.SourceURL
} else if m.MediaType == "image" {
if firstImage {
resp.ThumbnailID = &m.ID
resp.ThumbnailSourceURL = m.SourceURL
firstImage = false
} else {
resp.GalleryIDs = append(resp.GalleryIDs, m.ID)
}
}
}

View file

@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
@ -34,10 +35,61 @@ func nullStr(s string) sql.Null[string] {
return sql.Null[string]{V: s, Valid: true}
}
// downloadAndStoreImages downloads images from URLs and stores them for an item.
// All images are stored with media_type "image". The first image (by ID order) serves as the thumbnail.
// If imageURLs is empty but imageURL is set, stores that single image.
func downloadAndStoreImages(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
itemID int64,
imageURL string,
imageURLs []string,
) {
if len(imageURLs) > 0 {
// Multi-image (e.g., Twitter with multiple photos)
for i, imgURL := range imageURLs {
imgData, contentType, err := opengraph.DownloadImage(ctx, imgURL)
if err != nil {
logger.Warn("failed to download image", "url", imgURL, "index", i, "error", err)
continue
}
_, err = media.QCreate(ctx, db, media.CreateParams{
ItemID: itemID,
MediaType: "image",
ContentType: contentType,
Data: imgData,
SourceURL: &imgURL,
})
if err != nil {
logger.Warn("failed to store image", "index", i, "error", err)
}
}
} else if imageURL != "" {
// Single image
imgData, contentType, err := opengraph.DownloadImage(ctx, imageURL)
if err != nil {
logger.Warn("failed to download image", "url", imageURL, "error", err)
return
}
_, err = media.QCreate(ctx, db, media.CreateParams{
ItemID: itemID,
MediaType: "image",
ContentType: contentType,
Data: imgData,
SourceURL: &imageURL,
})
if err != nil {
logger.Warn("failed to store image", "error", err)
}
}
}
type urlMetadata struct {
Title string
Description string
ImageURL string
ImageURLs []string // All image URLs (for multi-image tweets)
SiteName string
IsEmbed bool
Provider string
@ -54,6 +106,7 @@ func fetchURLMetadata(ctx context.Context, url string) (*urlMetadata, error) {
Title: videoInfo.Title,
Description: videoInfo.Description,
ImageURL: videoInfo.ThumbnailURL,
ImageURLs: videoInfo.ThumbnailURLs,
IsEmbed: true,
Provider: string(videoInfo.Provider),
VideoID: videoInfo.VideoID,
@ -169,14 +222,16 @@ func HandleCreateFromLink(rc *RequestContext, w http.ResponseWriter, r *http.Req
itemType = "link"
}
// For embeds, fetch thumbnail and video URL
// 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
}
}
@ -196,24 +251,8 @@ func HandleCreateFromLink(rc *RequestContext, w http.ResponseWriter, r *http.Req
return err
}
// Download and store thumbnail
if imageURL != "" {
imgData, contentType, err := opengraph.DownloadImage(ctx, imageURL)
if err != nil {
rc.Logger.Warn("failed to download image", "url", imageURL, "error", err)
} else {
_, err = media.QCreate(ctx, rc.DB, media.CreateParams{
ItemID: it.ID,
MediaType: "thumbnail",
ContentType: contentType,
Data: imgData,
SourceURL: &imageURL,
})
if err != nil {
rc.Logger.Warn("failed to store image", "error", err)
}
}
}
// Download and store images
downloadAndStoreImages(ctx, rc.DB, rc.Logger, it.ID, imageURL, imageURLs)
// Set tags
if len(req.Tags) > 0 {
@ -547,26 +586,11 @@ func HandleRefreshMetadata(rc *RequestContext, w http.ResponseWriter, r *http.Re
item.QUpdateVideoURL(ctx, rc.DB, it.ID, meta.VideoURL)
}
// Download and replace thumbnail
if meta.ImageURL != "" {
// Delete existing thumbnails
// Download and replace images
if len(meta.ImageURLs) > 0 || meta.ImageURL != "" {
// Delete existing media (thumbnails and gallery)
media.QDeleteByItemID(ctx, rc.DB, it.ID)
imgData, contentType, err := opengraph.DownloadImage(ctx, meta.ImageURL)
if err != nil {
rc.Logger.Warn("failed to download image during refresh", "url", meta.ImageURL, "error", err)
} else {
_, err = media.QCreate(ctx, rc.DB, media.CreateParams{
ItemID: it.ID,
MediaType: "thumbnail",
ContentType: contentType,
Data: imgData,
SourceURL: &meta.ImageURL,
})
if err != nil {
rc.Logger.Warn("failed to store refreshed image", "error", err)
}
}
downloadAndStoreImages(ctx, rc.DB, rc.Logger, it.ID, meta.ImageURL, meta.ImageURLs)
}
// Refetch and return updated item

View file

@ -31,6 +31,8 @@ type homeItem struct {
ThumbnailID *int64
MediaID *int64
HasVideo bool
GalleryIDs []int64 // Additional images for multi-image embeds
ImageCount int // Total image count (1 + len(GalleryIDs))
}
func (h homeContent) Render(sw *ssr.Writer) error {
@ -57,6 +59,12 @@ func (h homeContent) Render(sw *ssr.Writer) error {
<blockquote>{{.Description}}</blockquote>
{{if .Title}}<cite> {{.Title}}</cite>{{end}}
</div>
{{else if .GalleryIDs}}
<div class="grid-item-images" data-gallery="true" data-count="{{.ImageCount}}">
<img src="/media/{{.ThumbnailID}}" alt="{{if .Title}}{{.Title}}{{else}}Image{{end}}" loading="lazy" class="active">
{{range .GalleryIDs}}<img src="/media/{{.}}" alt="Image" loading="lazy">{{end}}
</div>
<div class="gallery-indicator">{{.ImageCount}}</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}}
@ -189,20 +197,31 @@ func HandleHome(rc *RequestContext, w http.ResponseWriter, r *http.Request) erro
}
// 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)
if err != nil {
return err
}
firstImage := true
for _, m := range mediaList {
if m.MediaType == "thumbnail" {
hi.ThumbnailID = &m.ID
} else if m.MediaType == "original" {
if m.MediaType == "original" {
hi.MediaID = &m.ID
if strings.HasPrefix(m.ContentType, "video/") {
hi.HasVideo = true
}
} else if m.MediaType == "image" {
if firstImage {
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
if it.EmbedVideoURL.Valid && it.EmbedVideoURL.V != "" {

View file

@ -31,6 +31,7 @@ type itemPageData struct {
ThumbnailID *int64
MediaID *int64
MediaIsVideo bool
GalleryIDs []int64 // Additional images for multi-image embeds
}
func (c itemPageContent) Render(sw *ssr.Writer) error {
@ -60,22 +61,27 @@ func (c itemPageContent) Render(sw *ssr.Writer) error {
<span class="play-button">&#9654;</span>
</div>
</div>
{{else if .Item.GalleryIDs}}
<div class="image-gallery">
{{if .Item.ThumbnailID}}<a href="/media/{{.Item.ThumbnailID}}" target="_blank"><img src="/media/{{.Item.ThumbnailID}}" alt="{{if .Item.Title}}{{.Item.Title}}{{else}}Image{{end}}"></a>{{end}}
{{range .Item.GalleryIDs}}<a href="/media/{{.}}" target="_blank"><img src="/media/{{.}}" alt="Image"></a>{{end}}
</div>
{{else if .Item.ThumbnailID}}
<div class="image-container">
<img src="/media/{{.Item.ThumbnailID}}" alt="{{if .Item.Title}}{{.Item.Title}}{{else}}Embed{{end}}">
<a href="/media/{{.Item.ThumbnailID}}" target="_blank"><img src="/media/{{.Item.ThumbnailID}}" alt="{{if .Item.Title}}{{.Item.Title}}{{else}}Embed{{end}}"></a>
</div>
{{else if .Item.MediaID}}
<div class="image-container">
<img src="/media/{{.Item.MediaID}}" alt="{{if .Item.Title}}{{.Item.Title}}{{else}}Embed{{end}}">
<a href="/media/{{.Item.MediaID}}" target="_blank"><img src="/media/{{.Item.MediaID}}" alt="{{if .Item.Title}}{{.Item.Title}}{{else}}Embed{{end}}"></a>
</div>
{{end}}
{{else if .Item.MediaID}}
<div class="image-container">
<img src="/media/{{.Item.MediaID}}" alt="{{if .Item.Title}}{{.Item.Title}}{{else}}Image{{end}}">
<a href="/media/{{.Item.MediaID}}" target="_blank"><img src="/media/{{.Item.MediaID}}" alt="{{if .Item.Title}}{{.Item.Title}}{{else}}Image{{end}}"></a>
</div>
{{else if .Item.ThumbnailID}}
<div class="image-container">
<img src="/media/{{.Item.ThumbnailID}}" alt="{{if .Item.Title}}{{.Item.Title}}{{else}}Image{{end}}">
<a href="/media/{{.Item.ThumbnailID}}" target="_blank"><img src="/media/{{.Item.ThumbnailID}}" alt="{{if .Item.Title}}{{.Item.Title}}{{else}}Image{{end}}"></a>
</div>
{{end}}
@ -173,18 +179,26 @@ func HandleItemPage(rc *RequestContext, w http.ResponseWriter, r *http.Request)
}
// Get media
// Media is ordered by ID, so first "image" is the thumbnail, rest are gallery
var thumbnailID, mediaID *int64
var mediaIsVideo bool
var galleryIDs []int64
mediaList, err := media.QFindByItemID(ctx, rc.DB, it.ID)
if err != nil {
return err
}
firstImage := true
for _, m := range mediaList {
if m.MediaType == "thumbnail" {
thumbnailID = &m.ID
} else if m.MediaType == "original" {
if m.MediaType == "original" {
mediaID = &m.ID
mediaIsVideo = strings.HasPrefix(m.ContentType, "video/")
} else if m.MediaType == "image" {
if firstImage {
thumbnailID = &m.ID
firstImage = false
} else {
galleryIDs = append(galleryIDs, m.ID)
}
}
}
@ -201,6 +215,7 @@ func HandleItemPage(rc *RequestContext, w http.ResponseWriter, r *http.Request)
ThumbnailID: thumbnailID,
MediaID: mediaID,
MediaIsVideo: mediaIsVideo,
GalleryIDs: galleryIDs,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")