diff --git a/cmd/server/main.go b/cmd/server/main.go
index 05aa700..0545666 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -128,6 +128,7 @@ func runWebServer(dbURL string, logger *slog.Logger) {
// Media
router.Handle("GET /media/{id}", handlers.HandleGetMedia)
+ router.Handle("GET /proxy/video/{id}", handlers.HandleProxyVideo)
// Auth API
router.Handle("POST /api/auth/login", handlers.HandleLogin)
diff --git a/internal/handlers/item_page.go b/internal/handlers/item_page.go
index 72d9027..c66e246 100644
--- a/internal/handlers/item_page.go
+++ b/internal/handlers/item_page.go
@@ -53,9 +53,12 @@ func (c itemPageContent) Render(sw *ssr.Writer) error {
{{else if eq .Item.ItemType "embed"}}
{{if .Item.EmbedVideoURL}}
{{else if .Item.ThumbnailID}}
diff --git a/internal/handlers/proxy.go b/internal/handlers/proxy.go
new file mode 100644
index 0000000..df40fe7
--- /dev/null
+++ b/internal/handlers/proxy.go
@@ -0,0 +1,77 @@
+package handlers
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "time"
+
+ "lookbook/internal/data/item"
+)
+
+// HandleProxyVideo proxies video requests for embed items to avoid
+// cross-origin issues with external video CDNs (e.g., Twitter's video.twimg.com).
+// GET /proxy/video/{id}
+func HandleProxyVideo(rc *RequestContext, w http.ResponseWriter, r *http.Request) error {
+ pubID := r.PathValue("id")
+ if pubID == "" {
+ http.NotFound(w, r)
+ return nil
+ }
+
+ ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
+ defer cancel()
+
+ it, err := item.QFindByPubID(ctx, rc.DB, pubID)
+ if err != nil {
+ return err
+ }
+ if it == nil || !it.EmbedVideoURL.Valid || it.EmbedVideoURL.V == "" {
+ http.NotFound(w, r)
+ return nil
+ }
+
+ videoURL := it.EmbedVideoURL.V
+
+ req, err := http.NewRequestWithContext(ctx, "GET", videoURL, nil)
+ if err != nil {
+ return err
+ }
+
+ req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; Lookbook/1.0)")
+
+ // Pass through Range header for video seeking
+ if rangeHeader := r.Header.Get("Range"); rangeHeader != "" {
+ req.Header.Set("Range", rangeHeader)
+ }
+
+ client := &http.Client{Timeout: 2 * time.Minute}
+ resp, err := client.Do(req)
+ if err != nil {
+ http.Error(w, "Failed to fetch video", http.StatusBadGateway)
+ return nil
+ }
+ defer resp.Body.Close()
+
+ // Pass through relevant headers
+ if ct := resp.Header.Get("Content-Type"); ct != "" {
+ w.Header().Set("Content-Type", ct)
+ }
+ if cl := resp.Header.Get("Content-Length"); cl != "" {
+ w.Header().Set("Content-Length", cl)
+ }
+ if cr := resp.Header.Get("Content-Range"); cr != "" {
+ w.Header().Set("Content-Range", cr)
+ }
+ if ar := resp.Header.Get("Accept-Ranges"); ar != "" {
+ w.Header().Set("Accept-Ranges", ar)
+ }
+
+ // Cache for 1 day in the browser
+ w.Header().Set("Cache-Control", "public, max-age=86400")
+
+ w.WriteHeader(resp.StatusCode)
+ io.Copy(w, resp.Body)
+
+ return nil
+}
diff --git a/internal/static/css/app.css b/internal/static/css/app.css
index 07bc95a..a316fa5 100644
--- a/internal/static/css/app.css
+++ b/internal/static/css/app.css
@@ -296,6 +296,40 @@ button, input, textarea, select {
width: 100%;
}
+.video-container {
+ position: relative;
+}
+
+.video-overlay {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0, 0, 0, 0.3);
+ cursor: pointer;
+ transition: background 0.15s ease;
+}
+
+.video-overlay:hover {
+ background: rgba(0, 0, 0, 0.4);
+}
+
+.video-overlay .play-button {
+ width: 4rem;
+ height: 4rem;
+ background: rgba(0, 0, 0, 0.7);
+ color: #fff;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.5rem;
+ padding-left: 0.25rem;
+}
+
+
+
.embed-container {
position: relative;
}
diff --git a/internal/static/js/app.js b/internal/static/js/app.js
index 1863ba9..deb1287 100644
--- a/internal/static/js/app.js
+++ b/internal/static/js/app.js
@@ -378,6 +378,19 @@ async function deleteItem(id) {
}
}
+// Play video (initial click to start)
+function playVideo(overlay) {
+ const container = overlay.parentElement;
+ const video = container.querySelector('video');
+ if (video) {
+ video.play();
+ overlay.remove();
+ // Show controls on hover
+ video.addEventListener('mouseenter', () => video.controls = true);
+ video.addEventListener('mouseleave', () => video.controls = false);
+ }
+}
+
// Utility
function escapeHtml(str) {
const div = document.createElement('div');