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:
parent
8a046728ef
commit
4a2cb341fa
7 changed files with 249 additions and 137 deletions
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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 != "" {
|
||||||
|
|
|
||||||
|
|
@ -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">▶</span>
|
<span class="play-button">▶</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")
|
||||||
|
|
|
||||||
|
|
@ -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) */
|
||||||
|
|
|
||||||
|
|
@ -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>";
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue