nice clean recipes pear.dunkirk.sh
recipes
1
fork

Configure Feed

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

feat: add flagging

+244
+55
internal/cache/cache.go
··· 35 35 recipe JSON NOT NULL, 36 36 extraction_method TEXT NOT NULL, 37 37 fetched_at DATETIME NOT NULL 38 + ); 39 + CREATE TABLE IF NOT EXISTS flagged_recipes ( 40 + url TEXT PRIMARY KEY, 41 + recipe JSON NOT NULL, 42 + flagged_at DATETIME NOT NULL 38 43 ) 39 44 `) 40 45 return err ··· 88 93 rows, err := c.db.Query( 89 94 "SELECT url, recipe, extraction_method, fetched_at FROM recipes ORDER BY fetched_at DESC LIMIT ?", 90 95 limit, 96 + ) 97 + if err != nil { 98 + return nil, err 99 + } 100 + defer rows.Close() 101 + 102 + var results []models.CachedRecipe 103 + for rows.Next() { 104 + var cr models.CachedRecipe 105 + if err := rows.Scan(&cr.URL, &cr.Recipe, &cr.ExtractionMethod, &cr.FetchedAt); err != nil { 106 + return nil, err 107 + } 108 + results = append(results, cr) 109 + } 110 + return results, rows.Err() 111 + } 112 + 113 + func (c *Cache) Flag(url string, recipe *models.Recipe) error { 114 + recipeJSON, err := json.Marshal(recipe) 115 + if err != nil { 116 + return err 117 + } 118 + _, err = c.db.Exec( 119 + `INSERT INTO flagged_recipes (url, recipe, flagged_at) 120 + VALUES (?, ?, ?) 121 + ON CONFLICT(url) DO UPDATE SET recipe=excluded.recipe, flagged_at=excluded.flagged_at`, 122 + url, recipeJSON, time.Now(), 123 + ) 124 + return err 125 + } 126 + 127 + func (c *Cache) IsFlagged(url string) bool { 128 + var count int 129 + c.db.QueryRow("SELECT COUNT(*) FROM flagged_recipes WHERE url = ?", url).Scan(&count) 130 + return count > 0 131 + } 132 + 133 + func (c *Cache) Unflag(url string) error { 134 + _, err := c.db.Exec("DELETE FROM flagged_recipes WHERE url = ?", url) 135 + return err 136 + } 137 + 138 + func (c *Cache) Invalidate(url string) error { 139 + _, err := c.db.Exec("DELETE FROM recipes WHERE url = ?", url) 140 + return err 141 + } 142 + 143 + func (c *Cache) ListFlagged() ([]models.CachedRecipe, error) { 144 + rows, err := c.db.Query( 145 + "SELECT url, recipe, '', flagged_at FROM flagged_recipes ORDER BY flagged_at DESC", 91 146 ) 92 147 if err != nil { 93 148 return nil, err
+82
main.go
··· 88 88 r.Get("/recipe", srv.handleRecipeQuery) 89 89 r.Get("/userscript", srv.handleUserscript) 90 90 r.Get("/status", srv.handleStatus) 91 + r.Get("/flagged", srv.handleFlagged) 92 + r.Get("/flag", srv.handleFlag) 91 93 r.Get("/*", srv.handleRecipePath) 92 94 r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticContent)))) 93 95 ··· 188 190 189 191 targetURL := "https://" + path[1:] 190 192 193 + // Handle ?unflag query param 194 + if r.URL.Query().Has("unflag") { 195 + s.cache.Unflag(targetURL) 196 + http.Redirect(w, r, path, http.StatusSeeOther) 197 + return 198 + } 199 + 200 + // Handle ?refresh query param — clear cache and re-extract 201 + if r.URL.Query().Has("refresh") { 202 + s.cache.Invalidate(targetURL) 203 + s.failedMu.Lock() 204 + delete(s.failed, targetURL) 205 + s.failedMu.Unlock() 206 + // fall through to normal flow (no cache hit → re-extract) 207 + } 208 + 191 209 recipe, err := s.cache.Get(targetURL) 192 210 if err != nil { 193 211 log.Printf("cache read error: %v", err) ··· 332 350 "Filename": filename, 333 351 "GitHash": s.gitHash, 334 352 "BaseURL": s.baseURL, 353 + "IsFlagged": s.cache.IsFlagged(targetURL), 335 354 } 336 355 w.Header().Set("Content-Type", "text/html; charset=utf-8") 337 356 s.templates.ExecuteTemplate(w, "recipe_page", data) ··· 404 423 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 405 424 w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) 406 425 w.Write([]byte(cook)) 426 + } 427 + 428 + func (s *Server) handleFlag(w http.ResponseWriter, r *http.Request) { 429 + targetURL := r.URL.Query().Get("url") 430 + if targetURL == "" { 431 + targetURL = r.FormValue("url") 432 + } 433 + if targetURL == "" { 434 + http.Error(w, "missing url", http.StatusBadRequest) 435 + return 436 + } 437 + 438 + recipe, err := s.cache.Get(targetURL) 439 + if err != nil { 440 + log.Printf("cache read error: %v", err) 441 + } 442 + if recipe == nil { 443 + http.Error(w, "recipe not found", http.StatusNotFound) 444 + return 445 + } 446 + 447 + if err := s.cache.Flag(targetURL, recipe); err != nil { 448 + log.Printf("flag error: %v", err) 449 + http.Error(w, "flag failed", http.StatusInternalServerError) 450 + return 451 + } 452 + 453 + data := map[string]interface{}{ 454 + "SourceURL": targetURL, 455 + "GitHash": s.gitHash, 456 + "BaseURL": s.baseURL, 457 + } 458 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 459 + s.templates.ExecuteTemplate(w, "flag_page", data) 460 + } 461 + 462 + func (s *Server) handleFlagged(w http.ResponseWriter, r *http.Request) { 463 + flagged, err := s.cache.ListFlagged() 464 + if err != nil { 465 + log.Printf("list flagged error: %v", err) 466 + } 467 + 468 + var recipes []indexRecentRecipe 469 + for _, cr := range flagged { 470 + var recipe models.Recipe 471 + if err := json.Unmarshal(cr.Recipe, &recipe); err != nil { 472 + continue 473 + } 474 + recipes = append(recipes, indexRecentRecipe{ 475 + Name: recipe.Name, 476 + ImageURL: recipe.ImageURL, 477 + SourceURL: cr.URL, 478 + Domain: recipe.SourceDomain, 479 + }) 480 + } 481 + 482 + data := map[string]interface{}{ 483 + "GitHash": s.gitHash, 484 + "BaseURL": s.baseURL, 485 + "Flagged": recipes, 486 + } 487 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 488 + s.templates.ExecuteTemplate(w, "flagged_page", data) 407 489 } 408 490 409 491 func (s *Server) renderError(w http.ResponseWriter, errMsg, sourceURL string) {
+36
ui/static/style.css
··· 316 316 } 317 317 .cook-link:hover{color:var(--accent);background:var(--border);text-decoration:none} 318 318 319 + .flag-link{ 320 + font-size:0.7rem; 321 + color:var(--text-muted); 322 + background:var(--check-bg); 323 + padding:0.15rem 0.4rem; 324 + border-radius:4px; 325 + text-decoration:none; 326 + vertical-align:middle; 327 + margin-left:0.4rem; 328 + transition:all 0.15s; 329 + display:inline-flex; 330 + align-items:center; 331 + } 332 + .flag-link:hover{color:var(--accent);background:var(--border);text-decoration:none} 333 + .flag-link.flagged{color:#dc2626} 334 + .flag-link.flagged svg{fill:currentColor} 335 + 336 + .meta-link{ 337 + font-family:'Poppins',system-ui,sans-serif; 338 + font-size:0.65rem; 339 + color:var(--border); 340 + text-decoration:none; 341 + vertical-align:middle; 342 + margin-left:0.4rem; 343 + transition:color 0.15s; 344 + } 345 + .meta-link:hover{color:var(--text-muted);text-decoration:underline} 346 + 347 + .flagged-header{margin-bottom:1.5rem} 348 + .flagged-header h2{font-size:1.5rem;font-weight:600;font-family:'Poppins',system-ui,sans-serif} 349 + .flagged-empty{color:var(--text-muted);font-size:0.95rem} 350 + 351 + .flag-done{text-align:center;padding:3rem 1.5rem} 352 + .flag-done h3{font-family:'Poppins',system-ui,sans-serif;font-size:1.1rem;font-weight:600;margin:1rem 0 0.35rem} 353 + .flag-done p{color:var(--text-muted);font-size:0.9rem;margin-bottom:1.25rem} 354 + 319 355 .actions{ 320 356 display:flex; 321 357 gap:0.75rem;
+29
ui/templates/flag.html
··· 1 + {{define "flag_page"}} 2 + <!DOCTYPE html> 3 + <html lang="en"> 4 + <head> 5 + <meta charset="utf-8"> 6 + <meta name="viewport" content="width=device-width,initial-scale=1"> 7 + <title>Flagged</title> 8 + <link rel="icon" type="image/svg+xml" href="/static/favicon.svg"> 9 + <link rel="stylesheet" href="/static/style.css"> 10 + </head> 11 + <body> 12 + <nav> 13 + <a href="/" class="wordmark">pear</a> 14 + </nav> 15 + <div class="page"> 16 + <div class="flag-done"> 17 + <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="#dc2626" stroke="#dc2626" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 22V4a1 1 0 0 1 .4-.8A6 6 0 0 1 8 2c3 0 5 2 7.333 2q2 0 3.067-.8A1 1 0 0 1 20 4v10a1 1 0 0 1-.4.8A6 6 0 0 1 16 16c-3 0-5-2-8-2a6 6 0 0 0-4 1.528"/></svg> 18 + <h3>Thanks for flagging</h3> 19 + <p>This recipe has been saved for review.</p> 20 + <a href="/{{trimProto .SourceURL}}" class="cook-back">&larr; Back to recipe</a> 21 + </div> 22 + </div> 23 + <footer> 24 + <span>made by <a href="https://dunkirk.sh" target="_blank" rel="noopener">Kieran Klukas</a></span> 25 + <a href="https://tangled.org/dunkirk.sh/pear/commit/{{.GitHash}}" target="_blank" rel="noopener" class="commit-link">{{.GitHash}}</a> 26 + </footer> 27 + </body> 28 + </html> 29 + {{end}}
+41
ui/templates/flagged.html
··· 1 + {{define "flagged_page"}} 2 + <!DOCTYPE html> 3 + <html lang="en"> 4 + <head> 5 + <meta charset="utf-8"> 6 + <meta name="viewport" content="width=device-width,initial-scale=1"> 7 + <title>Flagged Recipes</title> 8 + <link rel="icon" type="image/svg+xml" href="/static/favicon.svg"> 9 + <link rel="stylesheet" href="/static/style.css"> 10 + </head> 11 + <body> 12 + <nav> 13 + <a href="/" class="wordmark">pear</a> 14 + </nav> 15 + <div class="page"> 16 + <div class="flagged-header"> 17 + <h2>Flagged Recipes</h2> 18 + </div> 19 + {{if .Flagged}} 20 + <div class="recent-grid"> 21 + {{range .Flagged}} 22 + <a href="/{{trimProto .SourceURL}}" class="recent-card"> 23 + {{if .ImageURL}}<img src="{{.ImageURL}}" alt="{{.Name}}" referrerpolicy="no-referrer">{{end}} 24 + <div class="recent-card-body"> 25 + <div class="recent-card-name">{{.Name}}</div> 26 + <div class="recent-card-domain">{{.Domain}}</div> 27 + </div> 28 + </a> 29 + {{end}} 30 + </div> 31 + {{else}} 32 + <p class="flagged-empty">No flagged recipes yet.</p> 33 + {{end}} 34 + </div> 35 + <footer> 36 + <span>made by <a href="https://dunkirk.sh" target="_blank" rel="noopener">Kieran Klukas</a></span> 37 + <a href="https://tangled.org/dunkirk.sh/pear/commit/{{.GitHash}}" target="_blank" rel="noopener" class="commit-link">{{.GitHash}}</a> 38 + </footer> 39 + </body> 40 + </html> 41 + {{end}}
+1
ui/templates/recipe.html
··· 40 40 {{if .Recipe.CookTime}}<span>⏱ {{fmtDuration .Recipe.CookTime}} cook</span>{{end}} 41 41 {{if .Recipe.Yield}}<span>Serves {{.Recipe.Yield}}</span>{{end}} 42 42 <a href="/cook?url={{.TargetURL | urlquery}}" class="cook-link">.cook</a> 43 + <a href="/flag?url={{.TargetURL | urlquery}}" class="flag-link{{if .IsFlagged}} flagged{{end}}" title="Flag as problematic – saves recipe for review"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 22V4a1 1 0 0 1 .4-.8A6 6 0 0 1 8 2c3 0 5 2 7.333 2q2 0 3.067-.8A1 1 0 0 1 20 4v10a1 1 0 0 1-.4.8A6 6 0 0 1 16 16c-3 0-5-2-8-2a6 6 0 0 0-4 1.528"/></svg></a> 43 44 </div> 44 45 {{if .Recipe.Description}}<p class="description">{{.Recipe.Description}}</p>{{end}} 45 46 </div>