diff --git a/internal/embed/detect.go b/internal/embed/detect.go index 7f6f2bd..97d4c5e 100644 --- a/internal/embed/detect.go +++ b/internal/embed/detect.go @@ -20,8 +20,8 @@ const ( ProviderTwitter Provider = "twitter" ) -// VideoInfo contains information about an embedded video. -type VideoInfo struct { +// EmbedInfo contains information about an embed (video, image gallery, or text). +type EmbedInfo struct { Provider Provider VideoID string Title string @@ -30,6 +30,7 @@ type VideoInfo struct { ThumbnailURLs []string // All thumbnail URLs (for multi-image tweets) EmbedHTML string VideoURL string // Direct video URL (for Twitter videos) + MediaType string // "video", "images", "text" - for Twitter only } var ( @@ -40,7 +41,7 @@ var ( ) // 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 if matches := youtubeRegex.FindStringSubmatch(targetURL); len(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 } -func fetchYouTube(ctx context.Context, videoID string) (*VideoInfo, error) { +func fetchYouTube(ctx context.Context, videoID string) (*EmbedInfo, error) { // YouTube thumbnails are available without API 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, ) - return &VideoInfo{ + return &EmbedInfo{ Provider: ProviderYouTube, VideoID: videoID, Title: title, @@ -86,7 +87,7 @@ func fetchYouTube(ctx context.Context, videoID string) (*VideoInfo, error) { }, 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", url.QueryEscape("https://vimeo.com/"+videoID)) @@ -100,7 +101,7 @@ func fetchVimeo(ctx context.Context, videoID string) (*VideoInfo, error) { videoID, ) - return &VideoInfo{ + return &EmbedInfo{ Provider: ProviderVimeo, VideoID: videoID, Title: meta.Title, @@ -110,7 +111,31 @@ func fetchVimeo(ctx context.Context, videoID string) (*VideoInfo, error) { }, 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 { Text string `json:"text"` User struct { @@ -138,7 +163,91 @@ type twitterSyndicationResponse struct { } `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( + `
`, + 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) 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 var thumbnailURL, videoURL string var thumbnailURLs []string + var mediaType string + if len(tweet.Photos) > 0 { thumbnailURL = tweet.Photos[0].URL // Collect all photo URLs for multi-image tweets for _, photo := range tweet.Photos { thumbnailURLs = append(thumbnailURLs, photo.URL) } + mediaType = "images" } else if len(tweet.MediaDetails) > 0 { media := tweet.MediaDetails[0] thumbnailURL = media.MediaURLHTTPS @@ -185,8 +297,12 @@ func fetchTwitter(ctx context.Context, tweetID string, originalURL string) (*Vid videoURL = v.URL } } + mediaType = "video" } + } else { + mediaType = "text" } + if 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 = strings.TrimSpace(description) - return &VideoInfo{ + return &EmbedInfo{ Provider: ProviderTwitter, VideoID: tweetID, Title: title, @@ -215,6 +331,7 @@ func fetchTwitter(ctx context.Context, tweetID string, originalURL string) (*Vid ThumbnailURLs: thumbnailURLs, VideoURL: videoURL, EmbedHTML: embedHTML, + MediaType: mediaType, }, nil } diff --git a/internal/handlers/api_items.go b/internal/handlers/api_items.go index 4885c76..c47f072 100644 --- a/internal/handlers/api_items.go +++ b/internal/handlers/api_items.go @@ -12,18 +12,17 @@ import ( ) type itemResponse struct { - ID string `json:"id"` - Title *string `json:"title,omitempty"` - Description *string `json:"description,omitempty"` - LinkURL *string `json:"linkUrl,omitempty"` - ItemType string `json:"itemType"` - EmbedHTML *string `json:"embedHtml,omitempty"` - Tags []string `json:"tags"` - CreatedAt string `json:"createdAt"` - 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) + ID string `json:"id"` + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + LinkURL *string `json:"linkUrl,omitempty"` + ItemType string `json:"itemType"` + EmbedHTML *string `json:"embedHtml,omitempty"` + Tags []string `json:"tags"` + CreatedAt string `json:"createdAt"` + MediaID *int64 `json:"mediaId,omitempty"` + ImageIDs []int64 `json:"imageIds,omitempty"` // Fetched images (from URLs/embeds) + ImageURLs []string `json:"imageUrls,omitempty"` // Source URLs for fetched images } type createItemRequest struct { @@ -252,22 +251,17 @@ 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 == "image" { - if firstImage { - resp.ThumbnailID = &m.ID - resp.ThumbnailSourceURL = m.SourceURL - firstImage = false - } else { - resp.GalleryIDs = append(resp.GalleryIDs, m.ID) + resp.ImageIDs = append(resp.ImageIDs, m.ID) + if m.SourceURL != nil { + resp.ImageURLs = append(resp.ImageURLs, *m.SourceURL) } } } diff --git a/internal/handlers/api_upload.go b/internal/handlers/api_upload.go index 960a5a3..3f8aa0d 100644 --- a/internal/handlers/api_upload.go +++ b/internal/handlers/api_upload.go @@ -96,22 +96,24 @@ type urlMetadata struct { VideoID string EmbedHTML string VideoURL string // Direct video URL (for Twitter) + MediaType string // "video", "images", "text" - for Twitter only } func fetchURLMetadata(ctx context.Context, url string) (*urlMetadata, error) { // Check if it's a YouTube/Vimeo/Twitter embed - videoInfo, err := embed.Detect(ctx, url) - if err == nil && videoInfo != nil { + embedInfo, err := embed.Detect(ctx, url) + if err == nil && embedInfo != nil { return &urlMetadata{ - Title: videoInfo.Title, - Description: videoInfo.Description, - ImageURL: videoInfo.ThumbnailURL, - ImageURLs: videoInfo.ThumbnailURLs, + Title: embedInfo.Title, + Description: embedInfo.Description, + ImageURL: embedInfo.ThumbnailURL, + ImageURLs: embedInfo.ThumbnailURLs, IsEmbed: true, - Provider: string(videoInfo.Provider), - VideoID: videoInfo.VideoID, - EmbedHTML: videoInfo.EmbedHTML, - VideoURL: videoInfo.VideoURL, + Provider: string(embedInfo.Provider), + VideoID: embedInfo.VideoID, + EmbedHTML: embedInfo.EmbedHTML, + VideoURL: embedInfo.VideoURL, + MediaType: embedInfo.MediaType, }, nil } @@ -139,6 +141,7 @@ type previewResponse struct { Provider string `json:"provider,omitempty"` VideoID string `json:"videoId,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 @@ -175,6 +178,7 @@ func HandlePreviewLink(rc *RequestContext, w http.ResponseWriter, r *http.Reques Provider: meta.Provider, VideoID: meta.VideoID, EmbedHTML: meta.EmbedHTML, + MediaType: meta.MediaType, }) } @@ -207,35 +211,58 @@ func HandleCreateFromLink(rc *RequestContext, w http.ResponseWriter, r *http.Req var itemType string var embedProvider, embedVideoID, embedHTML *string + var imageURL, videoURL string + var imageURLs []string // For multi-image tweets if req.Provider != nil && *req.Provider != "" { - // It's an embed - itemType = "embed" - embedProvider = req.Provider - embedVideoID = req.VideoID - embedHTML = req.EmbedHTML + // Special handling for Twitter based on media content + if *req.Provider == "twitter" { + // Fetch tweet info to determine content type + videoInfo, err := embed.Detect(ctx, req.URL) + 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 != "" { // It's a link with an image itemType = "image" + imageURL = *req.ImageURL } else { // Just a link (will be shown as a card) 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 it, err := item.QCreate(ctx, rc.DB, item.CreateParams{ Title: item.Nullable(req.Title), diff --git a/internal/handlers/home.go b/internal/handlers/home.go index 6de90fe..ce5bf26 100644 --- a/internal/handlers/home.go +++ b/internal/handlers/home.go @@ -28,11 +28,9 @@ type homeItem struct { ItemType string EmbedHTML *string Tags []string - ThumbnailID *int64 MediaID *int64 HasVideo bool - GalleryIDs []int64 // Additional images for multi-image embeds - ImageCount int // Total image count (1 + len(GalleryIDs)) + ImageIDs []int64 // Fetched images (from URLs/embeds) } func (h homeContent) Render(sw *ssr.Writer) error { @@ -54,33 +52,30 @@ func (h homeContent) Render(sw *ssr.Writer) error {
{{range .Items}} - {{if eq .ItemType "quote"}} -
-
{{.Description}}
- {{if .Title}}— {{.Title}}{{end}} -
- {{else if .GalleryIDs}} -
- {{if .Title}}{{.Title}}{{else}}Image{{end}} - {{range .GalleryIDs}}Image{{end}} -
- - {{else if .ThumbnailID}} - {{if .Title}}{{.Title}}{{else}}Image{{end}} - {{if or .HasVideo (eq .ItemType "video")}}
{{end}} - {{else if .MediaID}} - {{if .Title}}{{.Title}}{{else}}Image{{end}} - {{if or .HasVideo (eq .ItemType "video")}}
{{end}} - {{else if eq .ItemType "embed"}} -
- -
- {{else}} - - {{end}} + {{if eq .ItemType "quote"}} +
+
{{.Description}}
+ {{if .Title}}— {{.Title}}{{end}} +
+ {{else if .ImageIDs}} +
+ {{range $i, $id := .ImageIDs}}Image{{end}} +
+ {{if gt (len .ImageIDs) 1}}{{end}} + {{if .HasVideo}}
{{end}} + {{else if .MediaID}} + Image + {{if or .HasVideo (eq .ItemType "video")}}
{{end}} + {{else if eq .ItemType "embed"}} +
+ +
+ {{else}} + + {{end}} {{if or .Title .Tags}}
{{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 - // 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 == "original" { hi.MediaID = &m.ID @@ -210,18 +203,9 @@ func HandleHome(rc *RequestContext, w http.ResponseWriter, r *http.Request) erro hi.HasVideo = true } } else if m.MediaType == "image" { - if firstImage { - hi.ThumbnailID = &m.ID - firstImage = false - } else { - hi.GalleryIDs = append(hi.GalleryIDs, m.ID) - } + hi.ImageIDs = append(hi.ImageIDs, 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 != "" { diff --git a/internal/handlers/item_page.go b/internal/handlers/item_page.go index 2eec008..5dac3db 100644 --- a/internal/handlers/item_page.go +++ b/internal/handlers/item_page.go @@ -28,10 +28,9 @@ type itemPageData struct { EmbedVideoURL *string Tags []string CreatedAt string - ThumbnailID *int64 MediaID *int64 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 { @@ -51,37 +50,22 @@ func (c itemPageContent) Render(sw *ssr.Writer) error {
- {{else if eq .Item.ItemType "embed"}} - {{if .Item.EmbedVideoURL}} + {{else if .Item.EmbedVideoURL}}
-
- {{else if .Item.GalleryIDs}} -
- {{else if .Item.ThumbnailID}} -
- {{if .Item.Title}}{{.Item.Title}}{{else}}Embed{{end}} + {{else if .Item.ImageIDs}} +
+ {{range .Item.ImageIDs}}Image{{end}}
{{else if .Item.MediaID}}
- {{if .Item.Title}}{{.Item.Title}}{{else}}Embed{{end}} -
- {{end}} - {{else if .Item.MediaID}} -
- {{if .Item.Title}}{{.Item.Title}}{{else}}Image{{end}} -
- {{else if .Item.ThumbnailID}} -
- {{if .Item.Title}}{{.Item.Title}}{{else}}Image{{end}} + Image
{{end}} @@ -179,26 +163,19 @@ 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 mediaID *int64 var mediaIsVideo bool - var galleryIDs []int64 + var imageIDs []int64 mediaList, err := media.QFindByItemID(ctx, rc.DB, it.ID) if err != nil { return err } - firstImage := true for _, m := range mediaList { 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) - } + imageIDs = append(imageIDs, m.ID) } } @@ -212,10 +189,9 @@ func HandleItemPage(rc *RequestContext, w http.ResponseWriter, r *http.Request) EmbedVideoURL: item.Ptr(it.EmbedVideoURL), Tags: tagNames, CreatedAt: it.CreatedAt.Format("Jan 2, 2006"), - ThumbnailID: thumbnailID, MediaID: mediaID, MediaIsVideo: mediaIsVideo, - GalleryIDs: galleryIDs, + ImageIDs: imageIDs, } w.Header().Set("Content-Type", "text/html; charset=utf-8") diff --git a/internal/static/css/app.css b/internal/static/css/app.css index c74d5ca..4c34315 100644 --- a/internal/static/css/app.css +++ b/internal/static/css/app.css @@ -194,6 +194,7 @@ button, input, textarea, select { font-style: italic; line-height: 1.6; margin-bottom: 0.75rem; + white-space: pre-wrap; } .quote-card blockquote::before { @@ -376,6 +377,7 @@ button, input, textarea, select { font-style: italic; line-height: 1.6; margin-bottom: 1rem; + white-space: pre-wrap; } .quote-detail blockquote::before { @@ -405,6 +407,7 @@ button, input, textarea, select { .item-meta .description { margin-bottom: 1rem; color: var(--gray-3); + white-space: pre-wrap; } .item-meta .source-link { @@ -597,6 +600,7 @@ input[type="file"] { .preview-description { font-size: 0.85rem; color: var(--gray-3); + white-space: pre-wrap; } /* Image Gallery (detail page) */ diff --git a/internal/static/js/app.js b/internal/static/js/app.js index 5ed0bb7..056af26 100644 --- a/internal/static/js/app.js +++ b/internal/static/js/app.js @@ -183,7 +183,17 @@ async function fetchPreview(url) { html += `
${escapeHtml(data.description)}
`; } if (data.isEmbed) { - html += `
${escapeHtml(data.provider.toUpperCase())} VIDEO
`; + 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 += `
${escapeHtml(badge)}
`; } preview.innerHTML = html || "
No preview available
";