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
|
|
@ -9,7 +9,7 @@ import (
|
||||||
type Row struct {
|
type Row struct {
|
||||||
ID int64
|
ID int64
|
||||||
ItemID int64
|
ItemID int64
|
||||||
MediaType string // 'original', 'thumbnail'
|
MediaType string // 'original' (user uploads), 'image' (fetched from URLs)
|
||||||
ContentType string // MIME type
|
ContentType string // MIME type
|
||||||
Data []byte
|
Data []byte
|
||||||
Width *int
|
Width *int
|
||||||
|
|
@ -68,13 +68,13 @@ func QFindByID(ctx context.Context, db *sql.DB, id int64) (*Row, error) {
|
||||||
return &row, nil
|
return &row, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// QFindByItemID finds all media for an item.
|
// QFindByItemID finds all media for an item, ordered by ID.
|
||||||
func QFindByItemID(ctx context.Context, db *sql.DB, itemID int64) ([]Row, error) {
|
func QFindByItemID(ctx context.Context, db *sql.DB, itemID int64) ([]Row, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, item_id, media_type, content_type, data, width, height, source_url, created_at
|
SELECT id, item_id, media_type, content_type, data, width, height, source_url, created_at
|
||||||
FROM media
|
FROM media
|
||||||
WHERE item_id = $1
|
WHERE item_id = $1
|
||||||
ORDER BY media_type ASC
|
ORDER BY id ASC
|
||||||
`
|
`
|
||||||
|
|
||||||
rows, err := db.QueryContext(ctx, query, itemID)
|
rows, err := db.QueryContext(ctx, query, itemID)
|
||||||
|
|
@ -97,12 +97,13 @@ func QFindByItemID(ctx context.Context, db *sql.DB, itemID int64) ([]Row, error)
|
||||||
return media, rows.Err()
|
return media, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// QFindThumbnailByItemID finds the thumbnail for an item.
|
// QFindThumbnailByItemID finds the first image for an item (used as thumbnail).
|
||||||
func QFindThumbnailByItemID(ctx context.Context, db *sql.DB, itemID int64) (*Row, error) {
|
func QFindThumbnailByItemID(ctx context.Context, db *sql.DB, itemID int64) (*Row, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, item_id, media_type, content_type, data, width, height, source_url, created_at
|
SELECT id, item_id, media_type, content_type, data, width, height, source_url, created_at
|
||||||
FROM media
|
FROM media
|
||||||
WHERE item_id = $1 AND media_type = 'thumbnail'
|
WHERE item_id = $1 AND media_type = 'image'
|
||||||
|
ORDER BY id ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,14 @@ const (
|
||||||
|
|
||||||
// VideoInfo contains information about an embedded video.
|
// VideoInfo contains information about an embedded video.
|
||||||
type VideoInfo struct {
|
type VideoInfo struct {
|
||||||
Provider Provider
|
Provider Provider
|
||||||
VideoID string
|
VideoID string
|
||||||
Title string
|
Title string
|
||||||
Description string
|
Description string
|
||||||
ThumbnailURL string
|
ThumbnailURL string // First/primary thumbnail (for backward compatibility)
|
||||||
EmbedHTML string
|
ThumbnailURLs []string // All thumbnail URLs (for multi-image tweets)
|
||||||
VideoURL string // Direct video URL (for Twitter videos)
|
EmbedHTML string
|
||||||
|
VideoURL string // Direct video URL (for Twitter videos)
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -164,8 +165,13 @@ 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
|
||||||
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
|
||||||
|
for _, photo := range tweet.Photos {
|
||||||
|
thumbnailURLs = append(thumbnailURLs, photo.URL)
|
||||||
|
}
|
||||||
} 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
|
||||||
|
|
@ -201,13 +207,14 @@ func fetchTwitter(ctx context.Context, tweetID string, originalURL string) (*Vid
|
||||||
description = strings.TrimSpace(description)
|
description = strings.TrimSpace(description)
|
||||||
|
|
||||||
return &VideoInfo{
|
return &VideoInfo{
|
||||||
Provider: ProviderTwitter,
|
Provider: ProviderTwitter,
|
||||||
VideoID: tweetID,
|
VideoID: tweetID,
|
||||||
Title: title,
|
Title: title,
|
||||||
Description: description,
|
Description: description,
|
||||||
ThumbnailURL: thumbnailURL,
|
ThumbnailURL: thumbnailURL,
|
||||||
VideoURL: videoURL,
|
ThumbnailURLs: thumbnailURLs,
|
||||||
EmbedHTML: embedHTML,
|
VideoURL: videoURL,
|
||||||
|
EmbedHTML: embedHTML,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ type itemResponse struct {
|
||||||
MediaID *int64 `json:"mediaId,omitempty"`
|
MediaID *int64 `json:"mediaId,omitempty"`
|
||||||
ThumbnailID *int64 `json:"thumbnailId,omitempty"`
|
ThumbnailID *int64 `json:"thumbnailId,omitempty"`
|
||||||
ThumbnailSourceURL *string `json:"thumbnailSourceUrl,omitempty"`
|
ThumbnailSourceURL *string `json:"thumbnailSourceUrl,omitempty"`
|
||||||
|
GalleryIDs []int64 `json:"galleryIds,omitempty"` // Additional images (for multi-image tweets)
|
||||||
}
|
}
|
||||||
|
|
||||||
type createItemRequest struct {
|
type createItemRequest struct {
|
||||||
|
|
@ -251,16 +252,23 @@ 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 == "thumbnail" {
|
} else if m.MediaType == "image" {
|
||||||
resp.ThumbnailID = &m.ID
|
if firstImage {
|
||||||
resp.ThumbnailSourceURL = m.SourceURL
|
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"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -34,10 +35,61 @@ func nullStr(s string) sql.Null[string] {
|
||||||
return sql.Null[string]{V: s, Valid: true}
|
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 {
|
type urlMetadata struct {
|
||||||
Title string
|
Title string
|
||||||
Description string
|
Description string
|
||||||
ImageURL string
|
ImageURL string
|
||||||
|
ImageURLs []string // All image URLs (for multi-image tweets)
|
||||||
SiteName string
|
SiteName string
|
||||||
IsEmbed bool
|
IsEmbed bool
|
||||||
Provider string
|
Provider string
|
||||||
|
|
@ -54,6 +106,7 @@ func fetchURLMetadata(ctx context.Context, url string) (*urlMetadata, error) {
|
||||||
Title: videoInfo.Title,
|
Title: videoInfo.Title,
|
||||||
Description: videoInfo.Description,
|
Description: videoInfo.Description,
|
||||||
ImageURL: videoInfo.ThumbnailURL,
|
ImageURL: videoInfo.ThumbnailURL,
|
||||||
|
ImageURLs: videoInfo.ThumbnailURLs,
|
||||||
IsEmbed: true,
|
IsEmbed: true,
|
||||||
Provider: string(videoInfo.Provider),
|
Provider: string(videoInfo.Provider),
|
||||||
VideoID: videoInfo.VideoID,
|
VideoID: videoInfo.VideoID,
|
||||||
|
|
@ -169,14 +222,16 @@ func HandleCreateFromLink(rc *RequestContext, w http.ResponseWriter, r *http.Req
|
||||||
itemType = "link"
|
itemType = "link"
|
||||||
}
|
}
|
||||||
|
|
||||||
// For embeds, fetch thumbnail and video URL
|
// For embeds, fetch thumbnail(s) and video URL
|
||||||
var imageURL, videoURL string
|
var imageURL, videoURL string
|
||||||
|
var imageURLs []string // For multi-image tweets
|
||||||
if req.ImageURL != nil {
|
if req.ImageURL != nil {
|
||||||
imageURL = *req.ImageURL
|
imageURL = *req.ImageURL
|
||||||
}
|
}
|
||||||
if itemType == "embed" && embedProvider != nil {
|
if itemType == "embed" && embedProvider != nil {
|
||||||
if videoInfo, err := embed.Detect(ctx, req.URL); err == nil && videoInfo != nil {
|
if videoInfo, err := embed.Detect(ctx, req.URL); err == nil && videoInfo != nil {
|
||||||
imageURL = videoInfo.ThumbnailURL
|
imageURL = videoInfo.ThumbnailURL
|
||||||
|
imageURLs = videoInfo.ThumbnailURLs
|
||||||
videoURL = videoInfo.VideoURL
|
videoURL = videoInfo.VideoURL
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -196,24 +251,8 @@ func HandleCreateFromLink(rc *RequestContext, w http.ResponseWriter, r *http.Req
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download and store thumbnail
|
// Download and store images
|
||||||
if imageURL != "" {
|
downloadAndStoreImages(ctx, rc.DB, rc.Logger, it.ID, imageURL, imageURLs)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set tags
|
// Set tags
|
||||||
if len(req.Tags) > 0 {
|
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)
|
item.QUpdateVideoURL(ctx, rc.DB, it.ID, meta.VideoURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download and replace thumbnail
|
// Download and replace images
|
||||||
if meta.ImageURL != "" {
|
if len(meta.ImageURLs) > 0 || meta.ImageURL != "" {
|
||||||
// Delete existing thumbnails
|
// Delete existing media (thumbnails and gallery)
|
||||||
media.QDeleteByItemID(ctx, rc.DB, it.ID)
|
media.QDeleteByItemID(ctx, rc.DB, it.ID)
|
||||||
|
downloadAndStoreImages(ctx, rc.DB, rc.Logger, it.ID, meta.ImageURL, meta.ImageURLs)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refetch and return updated item
|
// Refetch and return updated item
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ type homeItem struct {
|
||||||
ThumbnailID *int64
|
ThumbnailID *int64
|
||||||
MediaID *int64
|
MediaID *int64
|
||||||
HasVideo bool
|
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 {
|
func (h homeContent) Render(sw *ssr.Writer) error {
|
||||||
|
|
@ -57,6 +59,12 @@ func (h homeContent) Render(sw *ssr.Writer) error {
|
||||||
<blockquote>{{.Description}}</blockquote>
|
<blockquote>{{.Description}}</blockquote>
|
||||||
{{if .Title}}<cite>— {{.Title}}</cite>{{end}}
|
{{if .Title}}<cite>— {{.Title}}</cite>{{end}}
|
||||||
</div>
|
</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}}
|
{{else if .ThumbnailID}}
|
||||||
<img src="/media/{{.ThumbnailID}}" alt="{{if .Title}}{{.Title}}{{else}}Image{{end}}" loading="lazy">
|
<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}}
|
{{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
|
// 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 == "thumbnail" {
|
if m.MediaType == "original" {
|
||||||
hi.ThumbnailID = &m.ID
|
|
||||||
} else if m.MediaType == "original" {
|
|
||||||
hi.MediaID = &m.ID
|
hi.MediaID = &m.ID
|
||||||
if strings.HasPrefix(m.ContentType, "video/") {
|
if strings.HasPrefix(m.ContentType, "video/") {
|
||||||
hi.HasVideo = true
|
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
|
// Also check for embed video URL
|
||||||
if it.EmbedVideoURL.Valid && it.EmbedVideoURL.V != "" {
|
if it.EmbedVideoURL.Valid && it.EmbedVideoURL.V != "" {
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ type itemPageData struct {
|
||||||
ThumbnailID *int64
|
ThumbnailID *int64
|
||||||
MediaID *int64
|
MediaID *int64
|
||||||
MediaIsVideo bool
|
MediaIsVideo bool
|
||||||
|
GalleryIDs []int64 // Additional images for multi-image embeds
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c itemPageContent) Render(sw *ssr.Writer) error {
|
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>
|
<span class="play-button">▶</span>
|
||||||
</div>
|
</div>
|
||||||
</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}}
|
{{else if .Item.ThumbnailID}}
|
||||||
<div class="image-container">
|
<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>
|
</div>
|
||||||
{{else if .Item.MediaID}}
|
{{else if .Item.MediaID}}
|
||||||
<div class="image-container">
|
<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>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{else if .Item.MediaID}}
|
{{else if .Item.MediaID}}
|
||||||
<div class="image-container">
|
<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>
|
</div>
|
||||||
{{else if .Item.ThumbnailID}}
|
{{else if .Item.ThumbnailID}}
|
||||||
<div class="image-container">
|
<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>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|
@ -173,18 +179,26 @@ 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 thumbnailID, mediaID *int64
|
var thumbnailID, mediaID *int64
|
||||||
var mediaIsVideo bool
|
var mediaIsVideo bool
|
||||||
|
var galleryIDs []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 == "thumbnail" {
|
if m.MediaType == "original" {
|
||||||
thumbnailID = &m.ID
|
|
||||||
} else 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" {
|
||||||
|
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,
|
ThumbnailID: thumbnailID,
|
||||||
MediaID: mediaID,
|
MediaID: mediaID,
|
||||||
MediaIsVideo: mediaIsVideo,
|
MediaIsVideo: mediaIsVideo,
|
||||||
|
GalleryIDs: galleryIDs,
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
|
|
||||||
8
internal/migrations/sql/0004_unify_image_media_type.sql
Normal file
8
internal/migrations/sql/0004_unify_image_media_type.sql
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
-- +goose Up
|
||||||
|
-- Consolidate 'thumbnail' and 'gallery' media types into unified 'image' type
|
||||||
|
UPDATE media SET media_type = 'image' WHERE media_type IN ('thumbnail', 'gallery');
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- Note: Cannot perfectly reverse since we lose the distinction between thumbnail and gallery
|
||||||
|
-- This sets all 'image' back to 'thumbnail' as a fallback
|
||||||
|
UPDATE media SET media_type = 'thumbnail' WHERE media_type = 'image';
|
||||||
|
|
@ -599,6 +599,66 @@ input[type="file"] {
|
||||||
color: var(--gray-3);
|
color: var(--gray-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Image Gallery (detail page) */
|
||||||
|
.image-gallery {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-gallery img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-gallery img:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Single image when odd count - make last one full width */
|
||||||
|
.image-gallery img:last-child:nth-child(odd) {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gallery Cycling (home grid) */
|
||||||
|
.grid-item-images {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-item-images img {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-item-images img:first-child {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-item-images img.active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gallery Indicator */
|
||||||
|
.gallery-indicator {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
.grid {
|
.grid {
|
||||||
|
|
|
||||||
|
|
@ -1,399 +1,479 @@
|
||||||
// Console-based authentication
|
// Console-based authentication
|
||||||
window.login = async (password) => {
|
window.login = async (password) => {
|
||||||
if (!password) {
|
if (!password) {
|
||||||
console.error('Usage: login("your-password")');
|
console.error('Usage: login("your-password")');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/auth/login', {
|
const res = await fetch("/api/auth/login", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ password })
|
body: JSON.stringify({ password }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
console.log(data.firstTime ? 'Password set! Reloading...' : 'Logged in! Reloading...');
|
console.log(
|
||||||
setTimeout(() => location.reload(), 500);
|
data.firstTime
|
||||||
} else {
|
? "Password set! Reloading..."
|
||||||
console.error(data.error || 'Login failed');
|
: "Logged in! Reloading...",
|
||||||
}
|
);
|
||||||
} catch (err) {
|
setTimeout(() => location.reload(), 500);
|
||||||
console.error('Login error:', err);
|
} else {
|
||||||
}
|
console.error(data.error || "Login failed");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Login error:", err);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.logout = async () => {
|
window.logout = async () => {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/auth/logout', { method: 'POST' });
|
await fetch("/api/auth/logout", { method: "POST" });
|
||||||
console.log('Logged out! Reloading...');
|
console.log("Logged out! Reloading...");
|
||||||
setTimeout(() => location.reload(), 500);
|
setTimeout(() => location.reload(), 500);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Logout error:', err);
|
console.error("Logout error:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Modal functions
|
// Modal functions
|
||||||
function showAddModal() {
|
function showAddModal() {
|
||||||
document.getElementById('add-modal').classList.add('active');
|
document.getElementById("add-modal").classList.add("active");
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideAddModal() {
|
function hideAddModal() {
|
||||||
document.getElementById('add-modal').classList.remove('active');
|
document.getElementById("add-modal").classList.remove("active");
|
||||||
}
|
}
|
||||||
|
|
||||||
function showEditModal() {
|
function showEditModal() {
|
||||||
document.getElementById('edit-modal').classList.add('active');
|
document.getElementById("edit-modal").classList.add("active");
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideEditModal() {
|
function hideEditModal() {
|
||||||
document.getElementById('edit-modal').classList.remove('active');
|
document.getElementById("edit-modal").classList.remove("active");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tab switching
|
// Tab switching
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const tabs = document.querySelectorAll('.modal-tabs .tab');
|
const tabs = document.querySelectorAll(".modal-tabs .tab");
|
||||||
tabs.forEach(tab => {
|
tabs.forEach((tab) => {
|
||||||
tab.addEventListener('click', () => {
|
tab.addEventListener("click", () => {
|
||||||
const tabId = tab.dataset.tab;
|
const tabId = tab.dataset.tab;
|
||||||
|
|
||||||
// Update tab buttons
|
// Update tab buttons
|
||||||
tabs.forEach(t => t.classList.remove('active'));
|
tabs.forEach((t) => t.classList.remove("active"));
|
||||||
tab.classList.add('active');
|
tab.classList.add("active");
|
||||||
|
|
||||||
// Update tab content
|
// Update tab content
|
||||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
document
|
||||||
document.getElementById('tab-' + tabId).classList.add('active');
|
.querySelectorAll(".tab-content")
|
||||||
});
|
.forEach((c) => c.classList.remove("active"));
|
||||||
});
|
document.getElementById("tab-" + tabId).classList.add("active");
|
||||||
|
});
|
||||||
// URL preview on input
|
});
|
||||||
const urlInput = document.querySelector('#link-form input[name="url"]');
|
|
||||||
if (urlInput) {
|
// URL preview on input
|
||||||
let debounceTimer;
|
const urlInput = document.querySelector('#link-form input[name="url"]');
|
||||||
urlInput.addEventListener('input', (e) => {
|
if (urlInput) {
|
||||||
clearTimeout(debounceTimer);
|
let debounceTimer;
|
||||||
debounceTimer = setTimeout(() => fetchPreview(e.target.value), 500);
|
urlInput.addEventListener("input", (e) => {
|
||||||
});
|
clearTimeout(debounceTimer);
|
||||||
}
|
debounceTimer = setTimeout(() => fetchPreview(e.target.value), 500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gallery hover cycling
|
||||||
|
const galleries = document.querySelectorAll('.grid-item-images[data-gallery="true"]');
|
||||||
|
galleries.forEach((gallery) => {
|
||||||
|
const images = gallery.querySelectorAll('img');
|
||||||
|
const indicator = gallery.parentElement.querySelector('.gallery-indicator');
|
||||||
|
if (images.length < 2) return;
|
||||||
|
|
||||||
|
let currentIndex = 0;
|
||||||
|
let cycleInterval = null;
|
||||||
|
|
||||||
|
const showImage = (index) => {
|
||||||
|
images.forEach((img, i) => {
|
||||||
|
img.classList.toggle('active', i === index);
|
||||||
|
});
|
||||||
|
if (indicator) {
|
||||||
|
indicator.textContent = `${index + 1}/${images.length}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startCycling = () => {
|
||||||
|
if (cycleInterval) return;
|
||||||
|
cycleInterval = setInterval(() => {
|
||||||
|
currentIndex = (currentIndex + 1) % images.length;
|
||||||
|
showImage(currentIndex);
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopCycling = () => {
|
||||||
|
if (cycleInterval) {
|
||||||
|
clearInterval(cycleInterval);
|
||||||
|
cycleInterval = null;
|
||||||
|
}
|
||||||
|
// Reset to first image
|
||||||
|
currentIndex = 0;
|
||||||
|
showImage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
gallery.parentElement.addEventListener('mouseenter', startCycling);
|
||||||
|
gallery.parentElement.addEventListener('mouseleave', stopCycling);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch URL preview
|
// Fetch URL preview
|
||||||
async function fetchPreview(url) {
|
async function fetchPreview(url) {
|
||||||
const preview = document.getElementById('link-preview');
|
const preview = document.getElementById("link-preview");
|
||||||
if (!url) {
|
if (!url) {
|
||||||
preview.classList.remove('active');
|
preview.classList.remove("active");
|
||||||
preview.innerHTML = '';
|
preview.innerHTML = "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/preview', {
|
const res = await fetch("/api/preview", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ url })
|
body: JSON.stringify({ url }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
preview.innerHTML = `<div class="preview-error">${data.error || 'Failed to fetch preview'}</div>`;
|
preview.innerHTML = `<div class="preview-error">${data.error || "Failed to fetch preview"}</div>`;
|
||||||
preview.classList.add('active');
|
preview.classList.add("active");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
preview.dataset.preview = JSON.stringify(data);
|
preview.dataset.preview = JSON.stringify(data);
|
||||||
|
|
||||||
let html = '';
|
let html = "";
|
||||||
if (data.imageUrl) {
|
if (data.imageUrl) {
|
||||||
html += `<img src="${escapeHtml(data.imageUrl)}" alt="Preview">`;
|
html += `<img src="${escapeHtml(data.imageUrl)}" alt="Preview">`;
|
||||||
}
|
}
|
||||||
if (data.title) {
|
if (data.title) {
|
||||||
html += `<div class="preview-title">${escapeHtml(data.title)}</div>`;
|
html += `<div class="preview-title">${escapeHtml(data.title)}</div>`;
|
||||||
}
|
}
|
||||||
if (data.description) {
|
if (data.description) {
|
||||||
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>`;
|
html += `<div class="preview-badge">${escapeHtml(data.provider.toUpperCase())} VIDEO</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
preview.innerHTML = html || '<div>No preview available</div>';
|
preview.innerHTML = html || "<div>No preview available</div>";
|
||||||
preview.classList.add('active');
|
preview.classList.add("active");
|
||||||
|
|
||||||
// Auto-fill title if empty
|
// Auto-fill title if empty
|
||||||
const titleInput = document.querySelector('#link-form input[name="title"]');
|
const titleInput = document.querySelector('#link-form input[name="title"]');
|
||||||
if (titleInput && !titleInput.value && data.title) {
|
if (titleInput && !titleInput.value && data.title) {
|
||||||
titleInput.value = data.title;
|
titleInput.value = data.title;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
console.error('Preview error:', err);
|
// Auto-fill description if empty
|
||||||
preview.innerHTML = '<div class="preview-error">Failed to fetch preview</div>';
|
const descriptionInput = document.querySelector(
|
||||||
preview.classList.add('active');
|
'#link-form textarea[name="description"]',
|
||||||
}
|
);
|
||||||
|
if (descriptionInput && !descriptionInput.value && data.description) {
|
||||||
|
descriptionInput.value = data.description;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Preview error:", err);
|
||||||
|
preview.innerHTML =
|
||||||
|
'<div class="preview-error">Failed to fetch preview</div>';
|
||||||
|
preview.classList.add("active");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit link form
|
// Submit link form
|
||||||
async function submitLink(event) {
|
async function submitLink(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const form = event.target;
|
const form = event.target;
|
||||||
const url = form.url.value;
|
const url = form.url.value;
|
||||||
const tags = form.tags.value ? form.tags.value.split(',').map(t => t.trim()).filter(Boolean) : [];
|
const tags = form.tags.value
|
||||||
|
? form.tags.value
|
||||||
// Get preview data
|
.split(",")
|
||||||
const preview = document.getElementById('link-preview');
|
.map((t) => t.trim())
|
||||||
const previewData = preview.dataset.preview ? JSON.parse(preview.dataset.preview) : {};
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
// Use form values, falling back to preview data
|
|
||||||
const title = form.title.value || previewData.title || null;
|
// Get preview data
|
||||||
const description = form.description.value || previewData.description || null;
|
const preview = document.getElementById("link-preview");
|
||||||
|
const previewData = preview.dataset.preview
|
||||||
try {
|
? JSON.parse(preview.dataset.preview)
|
||||||
const body = {
|
: {};
|
||||||
url,
|
|
||||||
title,
|
// Use form values, falling back to preview data
|
||||||
description,
|
const title = form.title.value || previewData.title || null;
|
||||||
tags,
|
const description = form.description.value || previewData.description || null;
|
||||||
imageUrl: previewData.imageUrl || null,
|
|
||||||
};
|
try {
|
||||||
|
const body = {
|
||||||
if (previewData.isEmbed) {
|
url,
|
||||||
body.provider = previewData.provider;
|
title,
|
||||||
body.videoId = previewData.videoId;
|
description,
|
||||||
body.embedHtml = previewData.embedHtml;
|
tags,
|
||||||
}
|
imageUrl: previewData.imageUrl || null,
|
||||||
|
};
|
||||||
const res = await fetch('/api/items/from-link', {
|
|
||||||
method: 'POST',
|
if (previewData.isEmbed) {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
body.provider = previewData.provider;
|
||||||
body: JSON.stringify(body)
|
body.videoId = previewData.videoId;
|
||||||
});
|
body.embedHtml = previewData.embedHtml;
|
||||||
|
}
|
||||||
if (res.ok) {
|
|
||||||
location.reload();
|
const res = await fetch("/api/items/from-link", {
|
||||||
} else {
|
method: "POST",
|
||||||
const data = await res.json();
|
headers: { "Content-Type": "application/json" },
|
||||||
alert(data.error || 'Failed to add item');
|
body: JSON.stringify(body),
|
||||||
}
|
});
|
||||||
} catch (err) {
|
|
||||||
console.error('Submit error:', err);
|
if (res.ok) {
|
||||||
alert('Failed to add item');
|
location.reload();
|
||||||
}
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
return false;
|
alert(data.error || "Failed to add item");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Submit error:", err);
|
||||||
|
alert("Failed to add item");
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit upload form
|
// Submit upload form
|
||||||
async function submitUpload(event) {
|
async function submitUpload(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const form = event.target;
|
const form = event.target;
|
||||||
const formData = new FormData(form);
|
const formData = new FormData(form);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/items/upload', {
|
const res = await fetch("/api/items/upload", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: formData
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
location.reload();
|
location.reload();
|
||||||
} else {
|
} else {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
alert(data.error || 'Failed to upload');
|
alert(data.error || "Failed to upload");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Upload error:', err);
|
console.error("Upload error:", err);
|
||||||
alert('Failed to upload');
|
alert("Failed to upload");
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit quote form
|
// Submit quote form
|
||||||
async function submitQuote(event) {
|
async function submitQuote(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const form = event.target;
|
const form = event.target;
|
||||||
const text = form.text.value;
|
const text = form.text.value;
|
||||||
const source = form.source.value || null;
|
const source = form.source.value || null;
|
||||||
const sourceUrl = form.sourceUrl.value || null;
|
const sourceUrl = form.sourceUrl.value || null;
|
||||||
const tags = form.tags.value ? form.tags.value.split(',').map(t => t.trim()).filter(Boolean) : [];
|
const tags = form.tags.value
|
||||||
|
? form.tags.value
|
||||||
try {
|
.split(",")
|
||||||
const res = await fetch('/api/items/quote', {
|
.map((t) => t.trim())
|
||||||
method: 'POST',
|
.filter(Boolean)
|
||||||
headers: { 'Content-Type': 'application/json' },
|
: [];
|
||||||
body: JSON.stringify({ text, source, sourceUrl, tags })
|
|
||||||
});
|
try {
|
||||||
|
const res = await fetch("/api/items/quote", {
|
||||||
if (res.ok) {
|
method: "POST",
|
||||||
location.reload();
|
headers: { "Content-Type": "application/json" },
|
||||||
} else {
|
body: JSON.stringify({ text, source, sourceUrl, tags }),
|
||||||
const data = await res.json();
|
});
|
||||||
alert(data.error || 'Failed to add quote');
|
|
||||||
}
|
if (res.ok) {
|
||||||
} catch (err) {
|
location.reload();
|
||||||
console.error('Submit error:', err);
|
} else {
|
||||||
alert('Failed to add quote');
|
const data = await res.json();
|
||||||
}
|
alert(data.error || "Failed to add quote");
|
||||||
|
}
|
||||||
return false;
|
} catch (err) {
|
||||||
|
console.error("Submit error:", err);
|
||||||
|
alert("Failed to add quote");
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edit item
|
// Edit item
|
||||||
function editItem(id) {
|
function editItem(id) {
|
||||||
showEditModal();
|
showEditModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit edit form
|
// Submit edit form
|
||||||
async function submitEdit(event) {
|
async function submitEdit(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const form = event.target;
|
const form = event.target;
|
||||||
const id = form.id.value;
|
const id = form.id.value;
|
||||||
const title = form.title.value || null;
|
const title = form.title.value || null;
|
||||||
const description = form.description.value || null;
|
const description = form.description.value || null;
|
||||||
const linkUrl = form.linkUrl.value || null;
|
const linkUrl = form.linkUrl.value || null;
|
||||||
const tags = form.tags.value ? form.tags.value.split(',').map(t => t.trim()).filter(Boolean) : [];
|
const tags = form.tags.value
|
||||||
|
? form.tags.value
|
||||||
try {
|
.split(",")
|
||||||
const res = await fetch(`/api/items/${id}`, {
|
.map((t) => t.trim())
|
||||||
method: 'PUT',
|
.filter(Boolean)
|
||||||
headers: { 'Content-Type': 'application/json' },
|
: [];
|
||||||
body: JSON.stringify({ title, description, linkUrl, tags })
|
|
||||||
});
|
try {
|
||||||
|
const res = await fetch(`/api/items/${id}`, {
|
||||||
if (res.ok) {
|
method: "PUT",
|
||||||
location.reload();
|
headers: { "Content-Type": "application/json" },
|
||||||
} else {
|
body: JSON.stringify({ title, description, linkUrl, tags }),
|
||||||
const data = await res.json();
|
});
|
||||||
alert(data.error || 'Failed to update');
|
|
||||||
}
|
if (res.ok) {
|
||||||
} catch (err) {
|
location.reload();
|
||||||
console.error('Update error:', err);
|
} else {
|
||||||
alert('Failed to update');
|
const data = await res.json();
|
||||||
}
|
alert(data.error || "Failed to update");
|
||||||
|
}
|
||||||
return false;
|
} catch (err) {
|
||||||
|
console.error("Update error:", err);
|
||||||
|
alert("Failed to update");
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh metadata
|
// Refresh metadata
|
||||||
async function refreshMetadata(id) {
|
async function refreshMetadata(id) {
|
||||||
try {
|
try {
|
||||||
// Fetch current item data
|
// Fetch current item data
|
||||||
const itemRes = await fetch(`/api/items/${id}`);
|
const itemRes = await fetch(`/api/items/${id}`);
|
||||||
if (!itemRes.ok) {
|
if (!itemRes.ok) {
|
||||||
alert('Failed to fetch item');
|
alert("Failed to fetch item");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const item = await itemRes.json();
|
const item = await itemRes.json();
|
||||||
|
|
||||||
if (!item.linkUrl) {
|
if (!item.linkUrl) {
|
||||||
alert('Item has no link URL');
|
alert("Item has no link URL");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch fresh metadata
|
// Fetch fresh metadata
|
||||||
const previewRes = await fetch('/api/preview', {
|
const previewRes = await fetch("/api/preview", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ url: item.linkUrl })
|
body: JSON.stringify({ url: item.linkUrl }),
|
||||||
});
|
});
|
||||||
if (!previewRes.ok) {
|
if (!previewRes.ok) {
|
||||||
const data = await previewRes.json();
|
const data = await previewRes.json();
|
||||||
alert(data.error || 'Failed to fetch metadata');
|
alert(data.error || "Failed to fetch metadata");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const preview = await previewRes.json();
|
const preview = await previewRes.json();
|
||||||
|
|
||||||
// Check if user has made manual edits
|
// Check if user has made manual edits
|
||||||
const titleChanged = item.title && preview.title && item.title !== preview.title;
|
const titleChanged =
|
||||||
const descChanged = item.description && preview.description && item.description !== preview.description;
|
item.title && preview.title && item.title !== preview.title;
|
||||||
const imageChanged = item.thumbnailSourceUrl && preview.imageUrl && item.thumbnailSourceUrl !== preview.imageUrl;
|
const descChanged =
|
||||||
|
item.description &&
|
||||||
if (titleChanged || descChanged || imageChanged) {
|
preview.description &&
|
||||||
let msg = 'This will overwrite your changes:\n';
|
item.description !== preview.description;
|
||||||
if (titleChanged) msg += `\nTitle: "${item.title}" → "${preview.title}"`;
|
const imageChanged =
|
||||||
if (descChanged) msg += `\nDescription will be replaced`;
|
item.thumbnailSourceUrl &&
|
||||||
if (imageChanged) msg += `\nImage will be replaced`;
|
preview.imageUrl &&
|
||||||
msg += '\n\nContinue?';
|
item.thumbnailSourceUrl !== preview.imageUrl;
|
||||||
|
|
||||||
if (!confirm(msg)) return;
|
if (titleChanged || descChanged || imageChanged) {
|
||||||
}
|
let msg = "This will overwrite your changes:\n";
|
||||||
|
if (titleChanged) msg += `\nTitle: "${item.title}" → "${preview.title}"`;
|
||||||
// Proceed with refresh
|
if (descChanged) msg += `\nDescription will be replaced`;
|
||||||
const res = await fetch(`/api/items/${id}/refresh`, { method: 'POST' });
|
if (imageChanged) msg += `\nImage will be replaced`;
|
||||||
|
msg += "\n\nContinue?";
|
||||||
if (res.ok) {
|
|
||||||
location.reload();
|
if (!confirm(msg)) return;
|
||||||
} else {
|
}
|
||||||
const data = await res.json();
|
|
||||||
alert(data.error || 'Failed to refresh');
|
// Proceed with refresh
|
||||||
}
|
const res = await fetch(`/api/items/${id}/refresh`, { method: "POST" });
|
||||||
} catch (err) {
|
|
||||||
console.error('Refresh error:', err);
|
if (res.ok) {
|
||||||
alert('Failed to refresh');
|
location.reload();
|
||||||
}
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
alert(data.error || "Failed to refresh");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Refresh error:", err);
|
||||||
|
alert("Failed to refresh");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace media
|
// Replace media
|
||||||
async function submitReplaceMedia(event, id) {
|
async function submitReplaceMedia(event, id) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const form = event.target;
|
const form = event.target;
|
||||||
const formData = new FormData(form);
|
const formData = new FormData(form);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/items/${id}/media`, {
|
const res = await fetch(`/api/items/${id}/media`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: formData
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
location.reload();
|
location.reload();
|
||||||
} else {
|
} else {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
alert(data.error || 'Failed to replace media');
|
alert(data.error || "Failed to replace media");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Replace media error:', err);
|
console.error("Replace media error:", err);
|
||||||
alert('Failed to replace media');
|
alert("Failed to replace media");
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete item
|
// Delete item
|
||||||
async function deleteItem(id) {
|
async function deleteItem(id) {
|
||||||
if (!confirm('Delete this item?')) return;
|
if (!confirm("Delete this item?")) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/items/${id}`, { method: 'DELETE' });
|
const res = await fetch(`/api/items/${id}`, { method: "DELETE" });
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
location.href = '/';
|
location.href = "/";
|
||||||
} else {
|
} else {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
alert(data.error || 'Failed to delete');
|
alert(data.error || "Failed to delete");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Delete error:', err);
|
console.error("Delete error:", err);
|
||||||
alert('Failed to delete');
|
alert("Failed to delete");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Play video (initial click to start)
|
// Play video (initial click to start)
|
||||||
function playVideo(overlay) {
|
function playVideo(overlay) {
|
||||||
const container = overlay.parentElement;
|
const container = overlay.parentElement;
|
||||||
const video = container.querySelector('video');
|
const video = container.querySelector("video");
|
||||||
if (video) {
|
if (video) {
|
||||||
video.play();
|
video.play();
|
||||||
overlay.remove();
|
overlay.remove();
|
||||||
// Show controls on hover
|
// Show controls on hover
|
||||||
video.addEventListener('mouseenter', () => video.controls = true);
|
video.addEventListener("mouseenter", () => (video.controls = true));
|
||||||
video.addEventListener('mouseleave', () => video.controls = false);
|
video.addEventListener("mouseleave", () => (video.controls = false));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Utility
|
// Utility
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement("div");
|
||||||
div.textContent = str;
|
div.textContent = str;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue