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:
parent
e917e67930
commit
007e167707
9 changed files with 625 additions and 403 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 != "" {
|
||||
|
|
|
|||
|
|
@ -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">▶</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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue