diff --git a/internal/data/item/queries.go b/internal/data/item/queries.go index b3719dc..3c95f72 100644 --- a/internal/data/item/queries.go +++ b/internal/data/item/queries.go @@ -9,45 +9,63 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +// Nullable creates a sql.Null[T] from a pointer. +func Nullable[T any](v *T) sql.Null[T] { + if v == nil { + return sql.Null[T]{} + } + return sql.Null[T]{V: *v, Valid: true} +} + +// Ptr returns a pointer from sql.Null[T], or nil if not valid. +func Ptr[T any](n sql.Null[T]) *T { + if !n.Valid { + return nil + } + return &n.V +} + type Row struct { ID int64 PubID string - Title *string - Description *string - LinkURL *string + Title sql.Null[string] + Description sql.Null[string] + LinkURL sql.Null[string] ItemType string // 'image', 'video', 'quote', 'embed' - EmbedProvider *string - EmbedVideoID *string - EmbedHTML *string + EmbedProvider sql.Null[string] + EmbedVideoID sql.Null[string] + EmbedHTML sql.Null[string] + EmbedVideoURL sql.Null[string] CreatedAt time.Time - DeletedAt *time.Time + DeletedAt sql.Null[time.Time] } type CreateParams struct { - Title *string - Description *string - LinkURL *string + Title sql.Null[string] + Description sql.Null[string] + LinkURL sql.Null[string] ItemType string - EmbedProvider *string - EmbedVideoID *string - EmbedHTML *string + EmbedProvider sql.Null[string] + EmbedVideoID sql.Null[string] + EmbedHTML sql.Null[string] + EmbedVideoURL sql.Null[string] } // QCreate creates a new item. func QCreate(ctx context.Context, db *sql.DB, p CreateParams) (Row, error) { query := ` - INSERT INTO item (title, description, link_url, item_type, embed_provider, embed_video_id, embed_html) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING id, pub_id, title, description, link_url, item_type, embed_provider, embed_video_id, embed_html, created_at, deleted_at + INSERT INTO item (title, description, link_url, item_type, embed_provider, embed_video_id, embed_html, embed_video_url) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, pub_id, title, description, link_url, item_type, embed_provider, embed_video_id, embed_html, embed_video_url, created_at, deleted_at ` var row Row var pubID pgtype.UUID err := db.QueryRowContext(ctx, query, - p.Title, p.Description, p.LinkURL, p.ItemType, p.EmbedProvider, p.EmbedVideoID, p.EmbedHTML, + p.Title, p.Description, p.LinkURL, p.ItemType, p.EmbedProvider, p.EmbedVideoID, p.EmbedHTML, p.EmbedVideoURL, ).Scan( &row.ID, &pubID, &row.Title, &row.Description, &row.LinkURL, - &row.ItemType, &row.EmbedProvider, &row.EmbedVideoID, &row.EmbedHTML, + &row.ItemType, &row.EmbedProvider, &row.EmbedVideoID, &row.EmbedHTML, &row.EmbedVideoURL, &row.CreatedAt, &row.DeletedAt, ) if err == nil { @@ -59,7 +77,7 @@ func QCreate(ctx context.Context, db *sql.DB, p CreateParams) (Row, error) { // QFindByPubID finds an item by its public ID. func QFindByPubID(ctx context.Context, db *sql.DB, pubID string) (*Row, error) { query := ` - SELECT id, pub_id, title, description, link_url, item_type, embed_provider, embed_video_id, embed_html, created_at, deleted_at + SELECT id, pub_id, title, description, link_url, item_type, embed_provider, embed_video_id, embed_html, embed_video_url, created_at, deleted_at FROM item WHERE pub_id = $1 ` @@ -68,7 +86,7 @@ func QFindByPubID(ctx context.Context, db *sql.DB, pubID string) (*Row, error) { var pubUUID pgtype.UUID err := db.QueryRowContext(ctx, query, pubID).Scan( &row.ID, &pubUUID, &row.Title, &row.Description, &row.LinkURL, - &row.ItemType, &row.EmbedProvider, &row.EmbedVideoID, &row.EmbedHTML, + &row.ItemType, &row.EmbedProvider, &row.EmbedVideoID, &row.EmbedHTML, &row.EmbedVideoURL, &row.CreatedAt, &row.DeletedAt, ) if err == sql.ErrNoRows { @@ -84,7 +102,7 @@ func QFindByPubID(ctx context.Context, db *sql.DB, pubID string) (*Row, error) { // QFindByID finds an item by its internal ID. func QFindByID(ctx context.Context, db *sql.DB, id int64) (*Row, error) { query := ` - SELECT id, pub_id, title, description, link_url, item_type, embed_provider, embed_video_id, embed_html, created_at, deleted_at + SELECT id, pub_id, title, description, link_url, item_type, embed_provider, embed_video_id, embed_html, embed_video_url, created_at, deleted_at FROM item WHERE id = $1 ` @@ -93,7 +111,7 @@ func QFindByID(ctx context.Context, db *sql.DB, id int64) (*Row, error) { var pubUUID pgtype.UUID err := db.QueryRowContext(ctx, query, id).Scan( &row.ID, &pubUUID, &row.Title, &row.Description, &row.LinkURL, - &row.ItemType, &row.EmbedProvider, &row.EmbedVideoID, &row.EmbedHTML, + &row.ItemType, &row.EmbedProvider, &row.EmbedVideoID, &row.EmbedHTML, &row.EmbedVideoURL, &row.CreatedAt, &row.DeletedAt, ) if err == sql.ErrNoRows { @@ -109,7 +127,7 @@ func QFindByID(ctx context.Context, db *sql.DB, id int64) (*Row, error) { // QList returns all non-deleted items, newest first. func QList(ctx context.Context, db *sql.DB) ([]Row, error) { query := ` - SELECT id, pub_id, title, description, link_url, item_type, embed_provider, embed_video_id, embed_html, created_at, deleted_at + SELECT id, pub_id, title, description, link_url, item_type, embed_provider, embed_video_id, embed_html, embed_video_url, created_at, deleted_at FROM item WHERE deleted_at IS NULL ORDER BY created_at DESC @@ -127,7 +145,7 @@ func QList(ctx context.Context, db *sql.DB) ([]Row, error) { var pubUUID pgtype.UUID if err := rows.Scan( &row.ID, &pubUUID, &row.Title, &row.Description, &row.LinkURL, - &row.ItemType, &row.EmbedProvider, &row.EmbedVideoID, &row.EmbedHTML, + &row.ItemType, &row.EmbedProvider, &row.EmbedVideoID, &row.EmbedHTML, &row.EmbedVideoURL, &row.CreatedAt, &row.DeletedAt, ); err != nil { return nil, err @@ -141,7 +159,7 @@ func QList(ctx context.Context, db *sql.DB) ([]Row, error) { // QListByTag returns all non-deleted items with a specific tag, newest first. func QListByTag(ctx context.Context, db *sql.DB, tagName string) ([]Row, error) { query := ` - SELECT i.id, i.pub_id, i.title, i.description, i.link_url, i.item_type, i.embed_provider, i.embed_video_id, i.embed_html, i.created_at, i.deleted_at + SELECT i.id, i.pub_id, i.title, i.description, i.link_url, i.item_type, i.embed_provider, i.embed_video_id, i.embed_html, i.embed_video_url, i.created_at, i.deleted_at FROM item i JOIN item_tag it ON i.id = it.item_id JOIN tag t ON it.tag_id = t.id @@ -161,7 +179,7 @@ func QListByTag(ctx context.Context, db *sql.DB, tagName string) ([]Row, error) var pubUUID pgtype.UUID if err := rows.Scan( &row.ID, &pubUUID, &row.Title, &row.Description, &row.LinkURL, - &row.ItemType, &row.EmbedProvider, &row.EmbedVideoID, &row.EmbedHTML, + &row.ItemType, &row.EmbedProvider, &row.EmbedVideoID, &row.EmbedHTML, &row.EmbedVideoURL, &row.CreatedAt, &row.DeletedAt, ); err != nil { return nil, err @@ -173,9 +191,9 @@ func QListByTag(ctx context.Context, db *sql.DB, tagName string) ([]Row, error) } type UpdateParams struct { - Title *string - Description *string - LinkURL *string + Title sql.Null[string] + Description sql.Null[string] + LinkURL sql.Null[string] } // QUpdate updates an item's editable fields. @@ -196,6 +214,13 @@ func QUpdateType(ctx context.Context, db *sql.DB, id int64, itemType string) err return err } +// QUpdateVideoURL updates an item's embed video URL. +func QUpdateVideoURL(ctx context.Context, db *sql.DB, id int64, videoURL string) error { + query := `UPDATE item SET embed_video_url = $2 WHERE id = $1` + _, err := db.ExecContext(ctx, query, id, videoURL) + return err +} + // QSoftDelete soft deletes an item. func QSoftDelete(ctx context.Context, db *sql.DB, id int64) error { query := `UPDATE item SET deleted_at = NOW() WHERE id = $1` diff --git a/internal/embed/detect.go b/internal/embed/detect.go index 107ad89..1faa6cc 100644 --- a/internal/embed/detect.go +++ b/internal/embed/detect.go @@ -7,6 +7,7 @@ import ( "net/http" "net/url" "regexp" + "strings" "time" ) @@ -27,12 +28,14 @@ type VideoInfo struct { Description string ThumbnailURL string EmbedHTML string + VideoURL string // Direct video URL (for Twitter videos) } var ( youtubeRegex = regexp.MustCompile(`(?:youtube\.com/(?:watch\?v=|embed/|v/|shorts/)|youtu\.be/)([a-zA-Z0-9_-]{11})`) vimeoRegex = regexp.MustCompile(`(?:vimeo\.com/(?:video/)?|player\.vimeo\.com/video/)(\d+)`) twitterRegex = regexp.MustCompile(`(?:twitter\.com|x\.com)/([^/]+)/status/(\d+)`) + tcoRegex = regexp.MustCompile(`\s*https://t\.co/\S+`) ) // Detect checks if a URL is a YouTube, Vimeo, or Twitter/X post and returns its info. @@ -121,6 +124,13 @@ type twitterSyndicationResponse struct { MediaDetails []struct { MediaURLHTTPS string `json:"media_url_https"` Type string `json:"type"` + VideoInfo struct { + Variants []struct { + ContentType string `json:"content_type"` + URL string `json:"url"` + Bitrate int `json:"bitrate,omitempty"` + } `json:"variants"` + } `json:"video_info"` } `json:"mediaDetails"` Video struct { Poster string `json:"poster"` @@ -152,13 +162,26 @@ func fetchTwitter(ctx context.Context, tweetID string, originalURL string) (*Vid return nil, fmt.Errorf("twitter syndication decode: %w", err) } - // Find thumbnail - prefer photos, then video poster - var thumbnailURL string + // Find thumbnail and video URL from media + var thumbnailURL, videoURL string if len(tweet.Photos) > 0 { thumbnailURL = tweet.Photos[0].URL } else if len(tweet.MediaDetails) > 0 { - thumbnailURL = tweet.MediaDetails[0].MediaURLHTTPS - } else if tweet.Video.Poster != "" { + media := tweet.MediaDetails[0] + thumbnailURL = media.MediaURLHTTPS + + // Extract video URL - find highest bitrate MP4 + if media.Type == "video" || media.Type == "animated_gif" { + var bestBitrate int + for _, v := range media.VideoInfo.Variants { + if v.ContentType == "video/mp4" && v.Bitrate >= bestBitrate { + bestBitrate = v.Bitrate + videoURL = v.URL + } + } + } + } + if thumbnailURL == "" && tweet.Video.Poster != "" { thumbnailURL = tweet.Video.Poster } @@ -173,12 +196,17 @@ func fetchTwitter(ctx context.Context, tweetID string, originalURL string) (*Vid title = fmt.Sprintf("%s (@%s)", tweet.User.Name, tweet.User.ScreenName) } + // Clean up tweet text - remove trailing t.co URLs + description := tcoRegex.ReplaceAllString(tweet.Text, "") + description = strings.TrimSpace(description) + return &VideoInfo{ Provider: ProviderTwitter, VideoID: tweetID, Title: title, - Description: tweet.Text, + Description: description, ThumbnailURL: thumbnailURL, + VideoURL: videoURL, EmbedHTML: embedHTML, }, nil } diff --git a/internal/handlers/api_items.go b/internal/handlers/api_items.go index 0b0a453..47e7f8b 100644 --- a/internal/handlers/api_items.go +++ b/internal/handlers/api_items.go @@ -118,13 +118,13 @@ func HandleCreateItem(rc *RequestContext, w http.ResponseWriter, r *http.Request defer cancel() it, err := item.QCreate(ctx, rc.DB, item.CreateParams{ - Title: req.Title, - Description: req.Description, - LinkURL: req.LinkURL, + Title: item.Nullable(req.Title), + Description: item.Nullable(req.Description), + LinkURL: item.Nullable(req.LinkURL), ItemType: req.ItemType, - EmbedProvider: req.EmbedProvider, - EmbedVideoID: req.EmbedVideoID, - EmbedHTML: req.EmbedHTML, + EmbedProvider: item.Nullable(req.EmbedProvider), + EmbedVideoID: item.Nullable(req.EmbedVideoID), + EmbedHTML: item.Nullable(req.EmbedHTML), }) if err != nil { return err @@ -173,9 +173,9 @@ func HandleUpdateItem(rc *RequestContext, w http.ResponseWriter, r *http.Request } if err := item.QUpdate(ctx, rc.DB, it.ID, item.UpdateParams{ - Title: req.Title, - Description: req.Description, - LinkURL: req.LinkURL, + Title: item.Nullable(req.Title), + Description: item.Nullable(req.Description), + LinkURL: item.Nullable(req.LinkURL), }); err != nil { return err } @@ -241,11 +241,11 @@ func buildItemResponse(ctx context.Context, rc *RequestContext, it item.Row) (it resp := itemResponse{ ID: it.PubID, - Title: it.Title, - Description: it.Description, - LinkURL: it.LinkURL, + Title: item.Ptr(it.Title), + Description: item.Ptr(it.Description), + LinkURL: item.Ptr(it.LinkURL), ItemType: it.ItemType, - EmbedHTML: it.EmbedHTML, + EmbedHTML: item.Ptr(it.EmbedHTML), Tags: tagNames, CreatedAt: it.CreatedAt.Format(time.RFC3339), } diff --git a/internal/handlers/api_upload.go b/internal/handlers/api_upload.go index 7df0a83..c114526 100644 --- a/internal/handlers/api_upload.go +++ b/internal/handlers/api_upload.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "database/sql" "encoding/json" "fmt" "io" @@ -17,6 +18,22 @@ import ( "lookbook/internal/video" ) +// strOrNil returns nil if s is empty, otherwise a pointer to s. +func strOrNil(s string) *string { + if s == "" { + return nil + } + return &s +} + +// nullStr creates a sql.Null[string] from a string (empty string = invalid). +func nullStr(s string) sql.Null[string] { + if s == "" { + return sql.Null[string]{} + } + return sql.Null[string]{V: s, Valid: true} +} + type urlMetadata struct { Title string Description string @@ -26,9 +43,9 @@ type urlMetadata struct { Provider string VideoID string EmbedHTML string + VideoURL string // Direct video URL (for Twitter) } -// fetchURLMetadata fetches metadata for a URL, trying embed detection first, then OpenGraph func fetchURLMetadata(ctx context.Context, url string) (*urlMetadata, error) { // Check if it's a YouTube/Vimeo/Twitter embed videoInfo, err := embed.Detect(ctx, url) @@ -41,6 +58,7 @@ func fetchURLMetadata(ctx context.Context, url string) (*urlMetadata, error) { Provider: string(videoInfo.Provider), VideoID: videoInfo.VideoID, EmbedHTML: videoInfo.EmbedHTML, + VideoURL: videoInfo.VideoURL, }, nil } @@ -151,33 +169,34 @@ func HandleCreateFromLink(rc *RequestContext, w http.ResponseWriter, r *http.Req itemType = "link" } + // For embeds, fetch thumbnail and video URL + var imageURL, videoURL string + 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 + videoURL = videoInfo.VideoURL + } + } + // Create the item it, err := item.QCreate(ctx, rc.DB, item.CreateParams{ - Title: req.Title, - Description: req.Description, - LinkURL: &req.URL, + Title: item.Nullable(req.Title), + Description: item.Nullable(req.Description), + LinkURL: item.Nullable(&req.URL), ItemType: itemType, - EmbedProvider: embedProvider, - EmbedVideoID: embedVideoID, - EmbedHTML: embedHTML, + EmbedProvider: item.Nullable(embedProvider), + EmbedVideoID: item.Nullable(embedVideoID), + EmbedHTML: item.Nullable(embedHTML), + EmbedVideoURL: nullStr(videoURL), }) if err != nil { return err } - // Download and store image if available - var imageURL string - if req.ImageURL != nil { - imageURL = *req.ImageURL - } - - // For embeds, fetch thumbnail - if itemType == "embed" && embedProvider != nil { - if videoInfo, err := embed.Detect(ctx, req.URL); err == nil && videoInfo != nil { - imageURL = videoInfo.ThumbnailURL - } - } - + // Download and store thumbnail if imageURL != "" { imgData, contentType, err := opengraph.DownloadImage(ctx, imageURL) if err != nil { @@ -277,17 +296,9 @@ func HandleUpload(rc *RequestContext, w http.ResponseWriter, r *http.Request) er } // Create item - var titlePtr, descPtr *string - if title != "" { - titlePtr = &title - } - if description != "" { - descPtr = &description - } - it, err := item.QCreate(ctx, rc.DB, item.CreateParams{ - Title: titlePtr, - Description: descPtr, + Title: item.Nullable(strOrNil(title)), + Description: item.Nullable(strOrNil(description)), ItemType: itemType, }) if err != nil { @@ -359,9 +370,9 @@ func HandleCreateQuote(rc *RequestContext, w http.ResponseWriter, r *http.Reques defer cancel() it, err := item.QCreate(ctx, rc.DB, item.CreateParams{ - Title: req.Source, - Description: &req.Text, - LinkURL: req.SourceURL, + Title: item.Nullable(req.Source), + Description: nullStr(req.Text), + LinkURL: item.Nullable(req.SourceURL), ItemType: "quote", }) if err != nil { @@ -517,32 +528,28 @@ func HandleRefreshMetadata(rc *RequestContext, w http.ResponseWriter, r *http.Re return writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) } - if it.LinkURL == nil || *it.LinkURL == "" { + if !it.LinkURL.Valid || it.LinkURL.V == "" { return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "item has no link URL"}) } - meta, err := fetchURLMetadata(ctx, *it.LinkURL) + meta, err := fetchURLMetadata(ctx, it.LinkURL.V) if err != nil { return writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("failed to fetch: %v", err)}) } - // Update title and description - var titlePtr, descPtr *string - if meta.Title != "" { - titlePtr = &meta.Title - } - if meta.Description != "" { - descPtr = &meta.Description - } + // Update title, description, and video URL item.QUpdate(ctx, rc.DB, it.ID, item.UpdateParams{ - Title: titlePtr, - Description: descPtr, + Title: nullStr(meta.Title), + Description: nullStr(meta.Description), LinkURL: it.LinkURL, }) + if meta.VideoURL != "" { + item.QUpdateVideoURL(ctx, rc.DB, it.ID, meta.VideoURL) + } // Download and replace thumbnail if meta.ImageURL != "" { - // Delete existing media for this item + // Delete existing thumbnails media.QDeleteByItemID(ctx, rc.DB, it.ID) imgData, contentType, err := opengraph.DownloadImage(ctx, meta.ImageURL) diff --git a/internal/handlers/home.go b/internal/handlers/home.go index eb8ba14..23c3654 100644 --- a/internal/handlers/home.go +++ b/internal/handlers/home.go @@ -3,6 +3,7 @@ package handlers import ( "context" "net/http" + "strings" "time" "git.soup.land/soup/sxgo/ssr" @@ -29,6 +30,7 @@ type homeItem struct { Tags []string ThumbnailID *int64 MediaID *int64 + HasVideo bool } func (h homeContent) Render(sw *ssr.Writer) error { @@ -57,8 +59,10 @@ func (h homeContent) Render(sw *ssr.Writer) error { {{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"}}
@@ -160,11 +164,11 @@ func HandleHome(rc *RequestContext, w http.ResponseWriter, r *http.Request) erro for _, it := range items { hi := homeItem{ ID: it.PubID, - Title: it.Title, - Description: it.Description, - LinkURL: it.LinkURL, + Title: item.Ptr(it.Title), + Description: item.Ptr(it.Description), + LinkURL: item.Ptr(it.LinkURL), ItemType: it.ItemType, - EmbedHTML: it.EmbedHTML, + EmbedHTML: item.Ptr(it.EmbedHTML), } // Get tags @@ -187,9 +191,17 @@ func HandleHome(rc *RequestContext, w http.ResponseWriter, r *http.Request) erro hi.ThumbnailID = &m.ID } else if m.MediaType == "original" { hi.MediaID = &m.ID + if strings.HasPrefix(m.ContentType, "video/") { + hi.HasVideo = true + } } } + // Also check for embed video URL + if it.EmbedVideoURL.Valid && it.EmbedVideoURL.V != "" { + hi.HasVideo = true + } + homeItems = append(homeItems, hi) } diff --git a/internal/handlers/item_page.go b/internal/handlers/item_page.go index c0bc609..72d9027 100644 --- a/internal/handlers/item_page.go +++ b/internal/handlers/item_page.go @@ -3,6 +3,7 @@ package handlers import ( "context" "net/http" + "strings" "time" "git.soup.land/soup/sxgo/ssr" @@ -18,16 +19,18 @@ type itemPageContent struct { } type itemPageData struct { - ID string - Title *string - Description *string - LinkURL *string - ItemType string - EmbedHTML *string - Tags []string - CreatedAt string - ThumbnailID *int64 - MediaID *int64 + ID string + Title *string + Description *string + LinkURL *string + ItemType string + EmbedHTML *string + EmbedVideoURL *string + Tags []string + CreatedAt string + ThumbnailID *int64 + MediaID *int64 + MediaIsVideo bool } func (c itemPageContent) Render(sw *ssr.Writer) error { @@ -48,7 +51,13 @@ func (c itemPageContent) Render(sw *ssr.Writer) error {
{{else if eq .Item.ItemType "embed"}} - {{if .Item.ThumbnailID}} + {{if .Item.EmbedVideoURL}} +
+ +
+ {{else if .Item.ThumbnailID}}
{{if .Item.Title}}{{.Item.Title}}{{else}}Embed{{end}}
@@ -162,6 +171,7 @@ func HandleItemPage(rc *RequestContext, w http.ResponseWriter, r *http.Request) // Get media var thumbnailID, mediaID *int64 + var mediaIsVideo bool mediaList, err := media.QFindByItemID(ctx, rc.DB, it.ID) if err != nil { return err @@ -171,28 +181,31 @@ func HandleItemPage(rc *RequestContext, w http.ResponseWriter, r *http.Request) thumbnailID = &m.ID } else if m.MediaType == "original" { mediaID = &m.ID + mediaIsVideo = strings.HasPrefix(m.ContentType, "video/") } } data := itemPageData{ - ID: it.PubID, - Title: it.Title, - Description: it.Description, - LinkURL: it.LinkURL, - ItemType: it.ItemType, - EmbedHTML: it.EmbedHTML, - Tags: tagNames, - CreatedAt: it.CreatedAt.Format("Jan 2, 2006"), - ThumbnailID: thumbnailID, - MediaID: mediaID, + ID: it.PubID, + Title: item.Ptr(it.Title), + Description: item.Ptr(it.Description), + LinkURL: item.Ptr(it.LinkURL), + ItemType: it.ItemType, + EmbedHTML: item.Ptr(it.EmbedHTML), + EmbedVideoURL: item.Ptr(it.EmbedVideoURL), + Tags: tagNames, + CreatedAt: it.CreatedAt.Format("Jan 2, 2006"), + ThumbnailID: thumbnailID, + MediaID: mediaID, + MediaIsVideo: mediaIsVideo, } w.Header().Set("Content-Type", "text/html; charset=utf-8") sw := ssr.NewWriter(w, rc.TmplCache) var title string - if it.Title != nil { - title = *it.Title + if it.Title.Valid { + title = it.Title.V } page := components.Page{ diff --git a/internal/migrations/sql/0003_embed_video_url.sql b/internal/migrations/sql/0003_embed_video_url.sql new file mode 100644 index 0000000..3a6efcb --- /dev/null +++ b/internal/migrations/sql/0003_embed_video_url.sql @@ -0,0 +1,5 @@ +-- +goose Up +ALTER TABLE item ADD COLUMN embed_video_url TEXT; + +-- +goose Down +ALTER TABLE item DROP COLUMN embed_video_url; diff --git a/internal/static/css/app.css b/internal/static/css/app.css index 3910d32..07bc95a 100644 --- a/internal/static/css/app.css +++ b/internal/static/css/app.css @@ -238,6 +238,22 @@ button, input, textarea, select { font-size: 2rem; } +/* Play Indicator */ +.play-indicator { + position: absolute; + bottom: 0.5rem; + right: 0.5rem; + width: 2rem; + height: 2rem; + background: rgba(0,0,0,0.7); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + pointer-events: none; +} + /* Item Tags */ .item-tags { display: flex;