this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Updated opengraph preview styling and special reddit handling

+221 -19
+95
internal/assets/css/screen.css
··· 252 252 text-align: left; 253 253 padding-bottom: 20px; 254 254 } 255 + 256 + /* Slack-like Link Preview Styles */ 257 + .og-card { 258 + margin-top: 8px; 259 + margin-bottom: 8px; 260 + padding-left: 12px; 261 + border-left: 4px solid #e0e0e0; 262 + font-family: "Slack-Lato", "Lato", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 263 + max-width: 600px; 264 + } 265 + 266 + .og-site-info { 267 + display: flex; 268 + align-items: center; 269 + font-size: 12px; 270 + font-weight: 700; 271 + color: #696969; /* Slack's lighter grey */ 272 + margin-bottom: 4px; 273 + } 274 + 275 + .og-site-icon { 276 + width: 16px; 277 + height: 16px; 278 + margin-right: 6px; 279 + border-radius: 2px; 280 + /* Reset styles from .item img */ 281 + padding: 0 !important; 282 + background: transparent !important; 283 + border: none !important; 284 + } 285 + 286 + .og-title { 287 + font-size: 16px; 288 + font-weight: 700; 289 + line-height: 24px; 290 + color: #1264a3; 291 + margin-bottom: 2px; 292 + } 293 + 294 + .og-title a { 295 + color: #1264a3; 296 + text-decoration: none; 297 + } 298 + 299 + .og-title a:hover { 300 + text-decoration: underline; 301 + } 302 + 303 + .og-description { 304 + font-size: 15px; 305 + line-height: 22px; 306 + color: #1d1c1d; 307 + margin-bottom: 8px; 308 + } 309 + 310 + .og-image { 311 + position: relative; /* For absolute positioning of play button */ 312 + display: inline-block; 313 + } 314 + 315 + .og-image img { 316 + max-width: 100%; 317 + border-radius: 8px; /* Slack likes rounder corners on media */ 318 + border: 1px solid #e0e0e0; 319 + display: block; 320 + /* Reset styles from .item img */ 321 + padding: 0 !important; 322 + background: transparent !important; 323 + } 324 + 325 + .og-play-button { 326 + position: absolute; 327 + top: 50%; 328 + left: 50%; 329 + transform: translate(-50%, -50%); 330 + width: 48px; 331 + height: 48px; 332 + background-color: rgba(0, 0, 0, 0.6); 333 + border-radius: 50%; 334 + pointer-events: none; /* Let clicks pass through to the link/video */ 335 + display: flex; 336 + justify-content: center; 337 + align-items: center; 338 + } 339 + 340 + .og-play-button::after { 341 + content: ''; 342 + display: block; 343 + width: 0; 344 + height: 0; 345 + border-left: 14px solid white; 346 + border-top: 9px solid transparent; 347 + border-bottom: 9px solid transparent; 348 + margin-left: 4px; /* Optical centering */ 349 + }
+98
internal/handler/preview.go
··· 2 2 3 3 import ( 4 4 "encoding/json" 5 + "fmt" 5 6 "net/http" 7 + "strings" 6 8 7 9 "golang.org/x/net/html" 8 10 ) ··· 15 17 if urlParam == "" { 16 18 json.NewEncoder(w).Encode(map[string]string{"error": "No URL provided"}) 17 19 return 20 + } 21 + 22 + // Reddit JSON API (better than oEmbed) 23 + if strings.Contains(urlParam, "reddit.com") { 24 + if meta, err := h.fetchRedditJSON(urlParam); err == nil { 25 + json.NewEncoder(w).Encode(meta) 26 + return 27 + } 28 + // Fallback to normal scraping 18 29 } 19 30 20 31 // Fetch data ··· 84 95 85 96 json.NewEncoder(w).Encode(metadata) 86 97 } 98 + 99 + func (h *Handler) fetchRedditJSON(url string) (map[string]string, error) { 100 + jsonURL := url + ".json" 101 + req, err := http.NewRequest("GET", jsonURL, nil) 102 + if err != nil { 103 + return nil, err 104 + } 105 + // Unique UA to ensure access 106 + req.Header.Set("User-Agent", "Tumble/1.0 (internal tool; +http://tumble.example.com)") 107 + 108 + client := &http.Client{} 109 + resp, err := client.Do(req) 110 + if err != nil { 111 + return nil, err 112 + } 113 + defer resp.Body.Close() 114 + 115 + if resp.StatusCode != 200 { 116 + return nil, fmt.Errorf("bad status: %d", resp.StatusCode) 117 + } 118 + 119 + var data []interface{} 120 + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { 121 + return nil, err 122 + } 123 + 124 + if len(data) == 0 { 125 + return nil, fmt.Errorf("no data") 126 + } 127 + 128 + meta := make(map[string]string) 129 + meta["provider_name"] = "Reddit" 130 + 131 + // Traverse: [0] -> data -> children -> [0] -> data 132 + if listing, ok := data[0].(map[string]interface{}); ok { 133 + if dataObj, ok := listing["data"].(map[string]interface{}); ok { 134 + if children, ok := dataObj["children"].([]interface{}); ok && len(children) > 0 { 135 + if child, ok := children[0].(map[string]interface{}); ok { 136 + if post, ok := child["data"].(map[string]interface{}); ok { 137 + if title, ok := post["title"].(string); ok { 138 + meta["title"] = title 139 + meta["og:title"] = title 140 + } 141 + 142 + // Construct description 143 + author, _ := post["author"].(string) 144 + sub, _ := post["subreddit_name_prefixed"].(string) 145 + if author != "" && sub != "" { 146 + meta["description"] = fmt.Sprintf("Posted by u/%s in %s", author, sub) 147 + } 148 + 149 + if hint, ok := post["post_hint"].(string); ok { 150 + if hint == "hosted:video" || hint == "rich:video" { 151 + meta["type"] = "video" 152 + } 153 + } 154 + 155 + // Image extraction 156 + // 1. Try 'preview' images (highest quality usually) 157 + foundImage := false 158 + if preview, ok := post["preview"].(map[string]interface{}); ok { 159 + if images, ok := preview["images"].([]interface{}); ok && len(images) > 0 { 160 + if img, ok := images[0].(map[string]interface{}); ok { 161 + if source, ok := img["source"].(map[string]interface{}); ok { 162 + if u, ok := source["url"].(string); ok { 163 + meta["image"] = strings.ReplaceAll(u, "&", "&") 164 + foundImage = true 165 + } 166 + } 167 + } 168 + } 169 + } 170 + 171 + // 2. Fallback to 'thumbnail' if valid URL 172 + if !foundImage { 173 + if thumb, ok := post["thumbnail"].(string); ok && strings.HasPrefix(thumb, "http") { 174 + meta["image"] = thumb 175 + } 176 + } 177 + } 178 + } 179 + } 180 + } 181 + } 182 + 183 + return meta, nil 184 + }
+28 -19
internal/templates/views/index.html
··· 69 69 } 70 70 71 71 // Build preview HTML 72 - var previewHTML = '<div class="og-preview-card">'; 72 + // Build preview HTML 73 + var previewHTML = '<div class="og-card">'; 73 74 75 + // Site Info (Icon + Name) 76 + var provider = data.provider_name || 'Link'; 77 + var iconUrl = '/favicon.ico'; 78 + if (provider === 'Reddit') iconUrl = 'https://www.redditstatic.com/desktop2x/img/favicon/favicon-32x32.png'; 79 + 80 + previewHTML += '<div class="og-site-info"><img src="' + iconUrl + '" class="og-site-icon" /> ' + escapeHtml(provider) + '</div>'; 81 + 82 + // Title 83 + if (title) { 84 + previewHTML += '<div class="og-title"><a href="' + escapeHtml(url) + '" target="_blank">' + escapeHtml(title) + '</a></div>'; 85 + } 86 + 87 + // Description 88 + if (description) { 89 + if (description.length > 300) { 90 + description = description.substring(0, 300) + '...'; 91 + } 92 + previewHTML += '<div class="og-description">' + escapeHtml(description) + '</div>'; 93 + } 94 + 95 + // Image 74 96 if (imageUrl) { 75 - previewHTML += '<div class="og-preview-image"><img src="' + escapeHtml(imageUrl) + '" alt="" /></div>'; 76 - } 77 - 78 - if (title || description) { 79 - previewHTML += '<div class="og-preview-content">'; 80 - 81 - if (title) { 82 - previewHTML += '<div class="og-preview-title">' + escapeHtml(title) + '</div>'; 83 - } 84 - 85 - if (description) { 86 - // Truncate description if too long 87 - if (description.length > 200) { 88 - description = description.substring(0, 200) + '...'; 97 + var imageContent = '<img src="' + escapeHtml(imageUrl) + '" alt="" />'; 98 + // If it's a video, add the play button overlay 99 + if (data.type === 'video') { 100 + imageContent += '<div class="og-play-button"></div>'; 89 101 } 90 - previewHTML += '<div class="og-preview-description">' + escapeHtml(description) + '</div>'; 91 - } 92 - 93 - previewHTML += '</div>'; 102 + previewHTML += '<div class="og-image ' + (data.type === 'video' ? 'is-video' : '') + '">' + imageContent + '</div>'; 94 103 } 95 104 96 105 previewHTML += '</div>';