From 29d94c13d5472ebeac81e663d1a718b7b50d5725 Mon Sep 17 00:00:00 2001 From: Thomas Cravey Date: Sun, 17 Aug 2025 18:24:05 -0500 Subject: [PATCH] =?UTF-8?q?feat(cards):=20YouTube=20oEmbed;=20UI:=20inline?= =?UTF-8?q?=20actions=20in=20header,=20=F0=9F=8C=9D/=F0=9F=8C=9A=20summari?= =?UTF-8?q?ze=20toggle,=20header=20collapse=20hides=20all?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/httpapi/server.go | 32 ++++++++++++++++++++++++++++++++ internal/httpapi/static/app.js | 6 +++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 39e3b80..02f251d 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -270,6 +270,38 @@ func (s *Server) handleLinkCard(w http.ResponseWriter, r *http.Request) { // fallthrough to generic fetch if oEmbed fails } + // Special handling for YouTube via oEmbed + if host == "www.youtube.com" || host == "youtube.com" || host == "m.youtube.com" || host == "youtu.be" { + watchURL := raw + if host == "youtu.be" { + // Convert youtu.be/ID to watch?v=ID + id := strings.TrimPrefix(u.Path, "/") + watchURL = "https://www.youtube.com/watch?v=" + id + } + oembed := "https://www.youtube.com/oembed?format=json&url=" + url.QueryEscape(watchURL) + client := &http.Client{Timeout: 8 * time.Second} + reqY, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, oembed, nil) + respY, errY := client.Do(reqY) + if errY == nil && respY.StatusCode >= 200 && respY.StatusCode < 300 { + defer respY.Body.Close() + var o struct{ + Title string `json:"title"` + Author string `json:"author_name"` + Thumb string `json:"thumbnail_url"` + HTML string `json:"html"` + } + if err := json.NewDecoder(respY.Body).Decode(&o); err == nil { + card := linkCard{URL: raw, Title: o.Title, Image: o.Thumb, HTML: o.HTML} + s.cardCache[raw] = card + s.cardCacheExp[raw] = time.Now().Add(24 * time.Hour) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(card) + return + } + } + // fallthrough if oEmbed fails + } + // fetch minimal HTML and extract tags using a tolerant HTML parser client := &http.Client{Timeout: 10 * time.Second} req, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, raw, nil) diff --git a/internal/httpapi/static/app.js b/internal/httpapi/static/app.js index b03d77d..dd52cfb 100644 --- a/internal/httpapi/static/app.js +++ b/internal/httpapi/static/app.js @@ -46,7 +46,7 @@ function processLinks(scope){ const links = scope.querySelectorAll('a[href]:not( left.appendChild(fav); left.appendChild(title); const actions=document.createElement('div'); actions.className='card-actions'; const chevron=document.createElement('button'); chevron.type='button'; chevron.title='Expand/collapse'; chevron.textContent='▾'; chevron.style.padding='0 .4rem'; chevron.style.fontSize='.9rem'; - const btn=document.createElement('button'); btn.type='button'; btn.title='Summarize this link'; btn.textContent='Summarize ✨'; btn.style.padding='0 .4rem'; btn.style.fontSize='.9rem'; + const btn=document.createElement('button'); btn.type='button'; btn.title='Summarize this link'; btn.textContent='🌝'; btn.style.padding='0 .4rem'; btn.style.fontSize='.9rem'; const spinner=document.createElement('span'); spinner.textContent=''; spinner.style.marginLeft='.5rem'; actions.appendChild(btn); actions.appendChild(chevron); actions.appendChild(spinner); head.appendChild(left); head.appendChild(actions); @@ -74,10 +74,10 @@ function processLinks(scope){ const links = scope.querySelectorAll('a[href]:not( btn.onclick = async ()=>{ if(sum.style.display!== 'none' && sum.textContent){ // hide existing - sum.style.display='none'; btn.textContent='Summarize ✨'; pinBottomMulti(); return; + sum.style.display='none'; btn.textContent='🌝'; pinBottomMulti(); return; } btn.disabled=true; spinner.textContent='…'; sum.textContent=''; sum.style.display=''; - try{ const data = await api('/api/linksummary',{query:{url:a.href}}); sum.textContent = (data && data.summary) ? data.summary : '(no summary)'; btn.textContent='Hide summary'; } + try{ const data = await api('/api/linksummary',{query:{url:a.href}}); sum.textContent = (data && data.summary) ? data.summary : '(no summary)'; btn.textContent='🌚'; } catch(e){ sum.textContent = 'error: '+e; } spinner.textContent=''; btn.disabled=false; pinBottomMulti(); };