nice clean recipes pear.dunkirk.sh
recipes
1
fork

Configure Feed

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

feat: update the ui

+437 -265
+6 -19
internal/cooklang/export.go
··· 61 61 } 62 62 63 63 var timeRe = regexp.MustCompile(`(?i)\b(\d+)\s*(seconds?|minutes?|mins?|hours?|hrs?|h)\b`) 64 - var cookwareRe = regexp.MustCompile(`(?i)\b(a|the)\s+(large|small|medium|big|heavy|deep|shallow|cast-iron|nonstick)\s+([\w-]+(?:\s+[\w-]+)?)\b`) 65 - var bareCookwareRe = regexp.MustCompile(`(?i)\b(saucepan|skillet|frying pan|baking sheet|baking dish|roasting pan|stockpot|dutch oven|slow cooker|instant pot|air fryer|pressure cooker|food processor|stand mixer|hand mixer|blender|grill|oven|stove|microwave|pot|pan|wok|bowl|whisk|spatula|tongs|colander|strainer|sieve|rolling pin|cutting board|knife|peeler|grater|mandoline|thermometer)\b`) 64 + 65 + func AnnotateStepForDisplay(text string, ingredients []models.Ingredient) string { 66 + index := buildIngredientIndex(ingredients) 67 + annotated, _ := annotateStep(text, index) 68 + return annotated 69 + } 66 70 67 71 func annotateStep(text string, ingredients map[string]models.Ingredient) (string, map[string]bool) { 68 72 annotated := text ··· 122 126 return fmt.Sprintf("~{%s%%%s}", qty, unit) 123 127 } 124 128 return matchStr 125 - }) 126 - 127 - annotated = cookwareRe.ReplaceAllStringFunc(annotated, func(matchStr string) string { 128 - parts := cookwareRe.FindStringSubmatch(matchStr) 129 - if len(parts) >= 4 { 130 - ware := parts[2] + " " + parts[3] 131 - return fmt.Sprintf("#%s{}", ware) 132 - } 133 - return matchStr 134 - }) 135 - 136 - annotated = bareCookwareRe.ReplaceAllStringFunc(annotated, func(matchStr string) string { 137 - ware := strings.ToLower(matchStr) 138 - if strings.Contains(ware, " ") { 139 - return fmt.Sprintf("#%s{}", ware) 140 - } 141 - return fmt.Sprintf("#%s", ware) 142 129 }) 143 130 144 131 return annotated, matched
+116
internal/cooklang/parse.go
··· 1 + package cooklang 2 + 3 + import ( 4 + "regexp" 5 + "strings" 6 + ) 7 + 8 + type AnnotatedStep struct { 9 + Text string 10 + Items []AnnotatedItem 11 + } 12 + 13 + type AnnotatedItem struct { 14 + Type string // "ingredient", "timer", "cookware" 15 + Name string 16 + Quantity string 17 + Unit string 18 + } 19 + 20 + var parseIngredientRe = regexp.MustCompile(`@([\w\s]+?)\{([^}%]*)(?:%([^}]*))?\}`) 21 + var parseTimerRe = regexp.MustCompile(`~([\w\s]*?)\{([^}%]*)(?:%([^}]*))?\}`) 22 + 23 + func ParseSteps(instructions []string) []AnnotatedStep { 24 + var steps []AnnotatedStep 25 + for _, text := range instructions { 26 + step := AnnotatedStep{Text: text} 27 + step.Items = parseAnnotations(text) 28 + steps = append(steps, step) 29 + } 30 + return steps 31 + } 32 + 33 + func parseAnnotations(text string) []AnnotatedItem { 34 + var items []AnnotatedItem 35 + 36 + matches := parseIngredientRe.FindAllStringSubmatchIndex(text, -1) 37 + for _, m := range matches { 38 + name := text[m[2]:m[3]] 39 + qty := "" 40 + unit := "" 41 + if m[4] != -1 { 42 + qty = text[m[4]:m[5]] 43 + } 44 + if m[6] != -1 { 45 + unit = text[m[6]:m[7]] 46 + } 47 + items = append(items, AnnotatedItem{Type: "ingredient", Name: name, Quantity: qty, Unit: unit}) 48 + } 49 + 50 + matches = parseTimerRe.FindAllStringSubmatchIndex(text, -1) 51 + for _, m := range matches { 52 + name := "" 53 + if m[2] != -1 { 54 + name = text[m[2]:m[3]] 55 + } 56 + qty := "" 57 + unit := "" 58 + if m[4] != -1 { 59 + qty = text[m[4]:m[5]] 60 + } 61 + if m[6] != -1 { 62 + unit = text[m[6]:m[7]] 63 + } 64 + items = append(items, AnnotatedItem{Type: "timer", Name: name, Quantity: qty, Unit: unit}) 65 + } 66 + 67 + return items 68 + } 69 + 70 + func RenderStepHTML(text string) string { 71 + out := text 72 + 73 + out = parseIngredientRe.ReplaceAllStringFunc(out, func(match string) string { 74 + parts := parseIngredientRe.FindStringSubmatch(match) 75 + name := strings.TrimSpace(parts[1]) 76 + qty := parts[2] 77 + unit := "" 78 + if len(parts) >= 4 && parts[3] != "" { 79 + unit = parts[3] 80 + } 81 + display := name 82 + if qty != "" { 83 + display = qty 84 + if unit != "" { 85 + display = qty + " " + unit + " " + name 86 + } 87 + } 88 + return `<span class="ing">` + escHTML(display) + `</span>` 89 + }) 90 + 91 + out = parseTimerRe.ReplaceAllStringFunc(out, func(match string) string { 92 + parts := parseTimerRe.FindStringSubmatch(match) 93 + qty := parts[2] 94 + unit := "" 95 + if len(parts) >= 4 && parts[3] != "" { 96 + unit = parts[3] 97 + } 98 + display := qty 99 + if unit != "" { 100 + display = qty + " " + unit 101 + } 102 + return `<span class="tmr">` + escHTML(display) + `</span>` 103 + }) 104 + 105 + return out 106 + } 107 + 108 + func escHTML(s string) string { 109 + s = strings.ReplaceAll(s, "&", "&amp;") 110 + s = strings.ReplaceAll(s, "<", "&lt;") 111 + s = strings.ReplaceAll(s, ">", "&gt;") 112 + s = strings.ReplaceAll(s, `"`, "&quot;") 113 + return s 114 + } 115 + 116 +
-168
internal/extract/ai/ai.go
··· 1 - package ai 2 - 3 - import ( 4 - "bytes" 5 - "encoding/json" 6 - "fmt" 7 - "io" 8 - "net/http" 9 - "strings" 10 - 11 - "tangled.org/dunkirk.sh/pare/internal/models" 12 - ) 13 - 14 - type Extractor struct { 15 - apiKey string 16 - model string 17 - baseURL string 18 - } 19 - 20 - func NewExtractor(apiKey, model, baseURL string) *Extractor { 21 - if model == "" { 22 - model = "claude-sonnet-4-20250514" 23 - } 24 - if baseURL == "" { 25 - baseURL = "https://api.anthropic.com/v1/messages" 26 - } 27 - return &Extractor{ 28 - apiKey: apiKey, 29 - model: model, 30 - baseURL: baseURL, 31 - } 32 - } 33 - 34 - func (e *Extractor) Extract(pageText, sourceURL string) (*models.Recipe, error) { 35 - if e.apiKey == "" { 36 - return nil, fmt.Errorf("AI extraction not configured: no API key set") 37 - } 38 - 39 - prompt := buildPrompt(pageText, sourceURL) 40 - 41 - reqBody := map[string]interface{}{ 42 - "model": e.model, 43 - "max_tokens": 4096, 44 - "messages": []map[string]string{ 45 - {"role": "user", "content": prompt}, 46 - }, 47 - } 48 - 49 - body, err := json.Marshal(reqBody) 50 - if err != nil { 51 - return nil, fmt.Errorf("marshaling request: %w", err) 52 - } 53 - 54 - req, err := http.NewRequest("POST", e.baseURL, bytes.NewReader(body)) 55 - if err != nil { 56 - return nil, fmt.Errorf("creating request: %w", err) 57 - } 58 - 59 - req.Header.Set("Content-Type", "application/json") 60 - req.Header.Set("x-api-key", e.apiKey) 61 - req.Header.Set("anthropic-version", "2023-06-01") 62 - 63 - resp, err := http.DefaultClient.Do(req) 64 - if err != nil { 65 - return nil, fmt.Errorf("making request: %w", err) 66 - } 67 - defer resp.Body.Close() 68 - 69 - if resp.StatusCode != http.StatusOK { 70 - respBody, _ := io.ReadAll(resp.Body) 71 - return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(respBody)) 72 - } 73 - 74 - var result struct { 75 - Content []struct { 76 - Text string `json:"text"` 77 - } `json:"content"` 78 - } 79 - 80 - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 81 - return nil, fmt.Errorf("decoding response: %w", err) 82 - } 83 - 84 - if len(result.Content) == 0 { 85 - return nil, fmt.Errorf("empty response from AI") 86 - } 87 - 88 - return parseAIResponse(result.Content[0].Text, sourceURL) 89 - } 90 - 91 - func buildPrompt(pageText, sourceURL string) string { 92 - return fmt.Sprintf(`Extract the recipe from the following page text. Return ONLY valid JSON matching this schema, no markdown fences: 93 - 94 - { 95 - "name": "string", 96 - "description": "string", 97 - "image_url": "string", 98 - "prep_time": "string (ISO 8601 duration like PT20M)", 99 - "cook_time": "string (ISO 8601 duration like PT40M)", 100 - "total_time": "string (ISO 8601 duration)", 101 - "yield": "string", 102 - "servings": 4, 103 - "ingredients": ["2 cups flour", "1 tsp salt"], 104 - "instructions": ["Step one text", "Step two text"] 105 - } 106 - 107 - Page text: 108 - %s`, truncate(pageText, 8000)) 109 - } 110 - 111 - func parseAIResponse(text, sourceURL string) (*models.Recipe, error) { 112 - cleaned := strings.TrimSpace(text) 113 - cleaned = strings.TrimPrefix(cleaned, "```json") 114 - cleaned = strings.TrimPrefix(cleaned, "```") 115 - cleaned = strings.TrimSuffix(cleaned, "```") 116 - cleaned = strings.TrimSpace(cleaned) 117 - 118 - var raw struct { 119 - Name string `json:"name"` 120 - Description string `json:"description"` 121 - ImageURL string `json:"image_url"` 122 - PrepTime string `json:"prep_time"` 123 - CookTime string `json:"cook_time"` 124 - TotalTime string `json:"total_time"` 125 - Yield string `json:"yield"` 126 - Servings int `json:"servings"` 127 - Ingredients []string `json:"ingredients"` 128 - Instructions []string `json:"instructions"` 129 - } 130 - 131 - if err := json.Unmarshal([]byte(cleaned), &raw); err != nil { 132 - return nil, fmt.Errorf("parsing AI JSON response: %w", err) 133 - } 134 - 135 - recipe := &models.Recipe{ 136 - Name: raw.Name, 137 - Description: raw.Description, 138 - ImageURL: raw.ImageURL, 139 - SourceURL: sourceURL, 140 - PrepTime: raw.PrepTime, 141 - CookTime: raw.CookTime, 142 - TotalTime: raw.TotalTime, 143 - Yield: raw.Yield, 144 - Servings: raw.Servings, 145 - ExtractionMethod: "ai", 146 - } 147 - 148 - for _, ing := range raw.Ingredients { 149 - recipe.Ingredients = append(recipe.Ingredients, models.Ingredient{RawText: ing}) 150 - } 151 - 152 - for _, step := range raw.Instructions { 153 - recipe.Instructions = append(recipe.Instructions, models.Instruction{Text: step}) 154 - } 155 - 156 - if recipe.Name == "" { 157 - return nil, fmt.Errorf("AI extraction returned empty recipe name") 158 - } 159 - 160 - return recipe, nil 161 - } 162 - 163 - func truncate(s string, maxLen int) string { 164 - if len(s) <= maxLen { 165 - return s 166 - } 167 - return s[:maxLen] + "..." 168 - }
+3 -36
internal/extract/pipeline.go
··· 7 7 "strings" 8 8 "time" 9 9 10 - "tangled.org/dunkirk.sh/pare/internal/extract/ai" 11 10 "tangled.org/dunkirk.sh/pare/internal/extract/hrecipe" 12 11 "tangled.org/dunkirk.sh/pare/internal/extract/schema" 13 12 "tangled.org/dunkirk.sh/pare/internal/models" 14 13 ) 15 14 16 15 type Pipeline struct { 17 - aiExtractor *ai.Extractor 18 - client *http.Client 16 + client *http.Client 19 17 } 20 18 21 - func NewPipeline(aiExtractor *ai.Extractor) *Pipeline { 19 + func NewPipeline() *Pipeline { 22 20 return &Pipeline{ 23 - aiExtractor: aiExtractor, 24 21 client: &http.Client{ 25 22 Timeout: 15 * time.Second, 26 23 }, ··· 50 47 return &Result{Recipe: recipe} 51 48 } 52 49 53 - if p.aiExtractor != nil { 54 - pageText := stripHTML(body) 55 - recipe, err := p.aiExtractor.Extract(pageText, targetURL) 56 - if err == nil && recipe != nil { 57 - recipe.SourceURL = targetURL 58 - recipe.SourceDomain = domainOf(targetURL) 59 - return &Result{Recipe: recipe} 60 - } 61 - } 62 - 63 - return &Result{Error: fmt.Errorf("no recipe found on page — tried JSON-LD, h-recipe, and AI extraction")} 50 + return &Result{Error: fmt.Errorf("no recipe found on page — tried JSON-LD and h-recipe extraction")} 64 51 } 65 52 66 53 func (p *Pipeline) fetch(url string) (string, error) { ··· 98 85 host := strings.Split(parts[1], "/")[0] 99 86 return host 100 87 } 101 - 102 - func stripHTML(body string) string { 103 - var sb strings.Builder 104 - inTag := false 105 - for _, r := range body { 106 - if r == '<' { 107 - inTag = true 108 - continue 109 - } 110 - if r == '>' { 111 - inTag = false 112 - sb.WriteRune(' ') 113 - continue 114 - } 115 - if !inTag { 116 - sb.WriteRune(r) 117 - } 118 - } 119 - return sb.String() 120 - }
+49 -9
main.go
··· 7 7 "log" 8 8 "net/http" 9 9 "net/url" 10 - "os" 10 + "strconv" 11 11 "strings" 12 12 "io/fs" 13 13 ··· 17 17 "tangled.org/dunkirk.sh/pare/internal/cache" 18 18 "tangled.org/dunkirk.sh/pare/internal/cooklang" 19 19 "tangled.org/dunkirk.sh/pare/internal/extract" 20 - "tangled.org/dunkirk.sh/pare/internal/extract/ai" 21 20 "tangled.org/dunkirk.sh/pare/internal/models" 22 21 "tangled.org/dunkirk.sh/pare/ui" 23 22 ) ··· 27 26 func main() { 28 27 port := flag.Int("port", 3000, "port to listen on") 29 28 dbPath := flag.String("db", "pare.db", "path to SQLite database") 30 - apiKey := flag.String("ai-key", os.Getenv("ANTHROPIC_API_KEY"), "Anthropic API key for AI extraction") 31 29 baseURL := flag.String("base-url", "", "base URL of this service") 32 30 flag.Parse() 33 31 ··· 41 39 } 42 40 defer c.Close() 43 41 44 - var aiExtractor *ai.Extractor 45 - if *apiKey != "" { 46 - aiExtractor = ai.NewExtractor(*apiKey, "", "") 47 - } 48 - 49 42 tmpl, err := template.New("").Funcs(template.FuncMap{ 50 43 "fmtDuration": fmtDuration, 44 + "isoToSeconds": isoToSeconds, 45 + "cleanSource": cleanSource, 46 + "renderStep": renderStep, 51 47 }).ParseFS(ui.Templates, "templates/*.html") 52 48 if err != nil { 53 49 log.Fatalf("parsing templates: %v", err) 54 50 } 55 51 56 52 srv := &Server{ 57 - pipeline: extract.NewPipeline(aiExtractor), 53 + pipeline: extract.NewPipeline(), 58 54 cache: c, 59 55 templates: tmpl, 60 56 baseURL: *baseURL, ··· 200 196 return strings.Join(parts, " ") 201 197 } 202 198 199 + func isoToSeconds(iso string) int { 200 + if !strings.HasPrefix(iso, "PT") { 201 + return 0 202 + } 203 + d := strings.TrimPrefix(iso, "PT") 204 + secs := 0 205 + if h := before(d, "H"); h != "" { 206 + if v, err := strconv.Atoi(h); err == nil { 207 + secs += v * 3600 208 + } 209 + d = after(d, "H") 210 + } 211 + if m := before(d, "M"); m != "" { 212 + if v, err := strconv.Atoi(m); err == nil { 213 + secs += v * 60 214 + } 215 + d = after(d, "M") 216 + } 217 + if s := before(d, "S"); s != "" { 218 + if v, err := strconv.Atoi(s); err == nil { 219 + secs += v 220 + } 221 + } 222 + return secs 223 + } 224 + 225 + func cleanSource(rawURL string) map[string]string { 226 + u, err := url.Parse(rawURL) 227 + if err != nil { 228 + return map[string]string{"host": rawURL, "path": ""} 229 + } 230 + host := strings.TrimPrefix(u.Host, "www.") 231 + path := u.Path 232 + if path == "/" { 233 + path = "" 234 + } 235 + return map[string]string{"host": host, "path": path} 236 + } 237 + 203 238 func before(s, sep string) string { 204 239 i := strings.Index(s, sep) 205 240 if i < 0 { ··· 214 249 return s 215 250 } 216 251 return s[i+len(sep):] 252 + } 253 + 254 + func renderStep(text string, ingredients []models.Ingredient) template.HTML { 255 + annotated := cooklang.AnnotateStepForDisplay(text, ingredients) 256 + return template.HTML(cooklang.RenderStepHTML(annotated)) 217 257 } 218 258 219 259 func recipeToJSONLD(r *models.Recipe) map[string]interface{} {
+23
ui/static/favicon.svg
··· 1 + <svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 + <g clip-path="url(#clip0_159_18)"> 3 + <g clip-path="url(#clip1_159_18)"> 4 + <path d="M610.486 166.576C532.055 155.499 496.488 198.56 462.07 254.306C435.728 296.953 411.765 362.371 361.591 415.001C311.417 467.631 196.813 529.241 189.618 686.258C182.506 843.285 273.819 969.695 503.916 1003.44C739.042 1037.99 868.501 891.449 886.24 729.643C901.205 592.724 791.693 497.973 760.423 418.397C712.666 296.821 751.157 186.398 610.486 166.576Z" fill="#B7D118"/> 5 + <path d="M568.368 27.2953C565.79 33.6118 571.609 86.6128 574.611 111.737C578.58 145.063 582.913 187.355 586.902 208.062C589.019 219.104 597.698 224.257 606.154 224.585C614.859 224.945 626.222 219.672 627.678 208.331C630.305 187.211 638.925 125.293 642.505 100.677C646.085 76.0613 655.286 26.671 655.879 19.4265C656.16 15.9284 636.18 9.66165 611.634 10.1293C586.922 10.5758 569.829 23.7804 568.368 27.2953Z" fill="#865C51"/> 6 + <path d="M517.462 286.239C495.841 281.613 486.924 307.815 474.178 340.258C461.348 372.69 437.656 412.395 412.963 441.537C371.102 491.027 331.707 514.748 303.02 565.171C276.198 612.216 265.705 676.923 311.584 689.879C364.693 704.858 380.965 633.151 393.038 603.988C405.581 573.791 422.288 545.896 448.932 512.039C468.444 487.197 513.807 418.298 526.733 366.104C539.92 313.187 543.499 291.853 517.462 286.239Z" fill="#E4F57D"/> 7 + <path d="M680.103 593.796C670.597 581.974 654.522 584.623 647.183 590.665C640.229 596.335 633.317 610.847 642.366 622.947C651.405 635.13 667.533 632.068 675.991 625.833C685.289 618.948 688.299 604.02 680.103 593.796Z" fill="#E4DC9F"/> 8 + <path d="M716.527 623.378C705.637 632.835 706.416 647.745 713.203 655.179C719.907 662.603 734.708 663.325 743.597 655.041C754.386 645.066 750.48 630.26 745.611 624.923C740.836 619.514 726.847 614.437 716.527 623.378Z" fill="#E4DC9F"/> 9 + <path d="M774.973 661.677C761.454 670.628 765.139 686.5 769.642 692.715C775.878 701.173 790.925 702.599 799.478 695.619C808.031 688.639 812.17 673.435 803.657 665.022C795.321 656.547 783.719 655.815 774.973 661.677Z" fill="#E4DC9F"/> 10 + <path d="M684.05 514.533C674.229 522.865 674.543 537.463 680.545 544.46C686.547 551.457 702.508 558.218 713.367 545.728C721.961 535.808 717.728 522.895 711.972 516.603C705.835 510.009 692.271 507.511 684.05 514.533Z" fill="#E4DC9F"/> 11 + <path d="M612.511 506.782C600.852 516.981 605.879 530.921 611.435 538.114C617.713 546.24 632.214 549.952 641.277 542.28C650.329 534.691 651.252 521.599 645.25 511.32C639.249 501.041 621.617 498.778 612.511 506.782Z" fill="#E4DC9F"/> 12 + <path d="M674.072 400.835C665.366 394.585 656.344 399.317 651.307 404.476C646.258 409.718 645.017 422.013 651.293 427.53C657.558 433.131 670.397 435.368 677.051 426.797C683.123 418.826 682.933 407.189 674.072 400.835Z" fill="#E4DC9F"/> 13 + </g> 14 + </g> 15 + <defs> 16 + <clipPath id="clip0_159_18"> 17 + <rect width="1024" height="1024" fill="white"/> 18 + </clipPath> 19 + <clipPath id="clip1_159_18"> 20 + <rect width="1068.29" height="1068.29" fill="white" transform="translate(100.001 -86) rotate(7.314)"/> 21 + </clipPath> 22 + </defs> 23 + </svg>
+122 -22
ui/static/style.css
··· 1 + @import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&family=Work+Sans:wght@400;500&display=swap'); 1 2 *,*::before,*::after{box-sizing:border-box;margin:0;padding:0} 2 3 3 4 :root{ ··· 19 20 html{font-size:16px;-webkit-font-smoothing:antialiased} 20 21 21 22 body{ 22 - font-family:Georgia,"Times New Roman",serif; 23 + font-family:'Work Sans',Georgia,"Times New Roman",serif; 23 24 background:var(--bg); 24 25 color:var(--text); 25 - line-height:1.7; 26 + line-height:1.5; 26 27 min-height:100vh; 27 28 display:flex; 28 29 flex-direction:column; ··· 30 31 31 32 a{color:var(--accent);text-decoration:none} 32 33 a:hover{text-decoration:underline} 34 + a[target="_blank"]::after{content:" ⤴";font-size:0.85em;color:var(--text-muted)} 35 + a[target="_blank"]:hover::after{color:var(--accent)} 36 + a.no-ext::after{content:none} 33 37 34 38 nav{ 35 39 max-width:var(--max-w); ··· 38 42 width:100%; 39 43 } 40 44 nav a.wordmark{ 41 - font-family:system-ui,sans-serif; 45 + font-family:'Poppins',system-ui,sans-serif; 42 46 font-size:1.6rem; 43 47 font-weight:800; 44 48 color:var(--accent); ··· 61 65 padding:1.25rem 2rem; 62 66 color:var(--text-muted); 63 67 font-size:0.8rem; 64 - font-family:system-ui,sans-serif; 68 + font-family:'Poppins',system-ui,sans-serif; 65 69 border-top:1px solid var(--border); 66 70 margin-top:auto; 67 71 } 68 72 footer a{color:var(--text-muted)} 69 73 footer a:hover{color:var(--accent)} 70 - .commit-link{font-family:monospace;font-size:0.75rem} 74 + .commit-link{font-size:0.75rem} 71 75 76 + .hero h1{ 77 + font-family:'Poppins',system-ui,sans-serif; 78 + font-size:3.75rem; 79 + line-height:1.1; 80 + letter-spacing:-0.03125em; 81 + font-weight:700; 82 + } 72 83 .hero{text-align:center;padding:4rem 0 2.5rem} 73 84 .hero p{color:var(--text-muted);font-size:1.05rem;margin-bottom:2rem} 74 85 ··· 96 107 border:none; 97 108 border-radius:var(--radius); 98 109 font-size:1.05rem; 99 - font-family:system-ui,sans-serif; 110 + font-family:'Poppins',system-ui,sans-serif; 100 111 font-weight:600; 101 112 cursor:pointer; 102 113 transition:background 0.2s; 103 114 } 104 115 .search-form button:hover{background:var(--accent-hover)} 105 116 117 + .recipe-image-wrapper{ 118 + margin-bottom:1.5rem; 119 + } 106 120 .recipe-image{ 107 121 width:100%; 108 122 max-height:400px; 109 123 object-fit:cover; 110 124 border-radius:var(--radius); 111 - margin-bottom:1.5rem; 125 + } 126 + .recipe-source{ 127 + text-align:right; 128 + font-size:0.8rem; 129 + margin-top:0.35rem; 130 + font-family:'Poppins',system-ui,sans-serif; 131 + } 132 + .recipe-source a svg{ 133 + margin-right:0.25rem; 134 + vertical-align:middle; 135 + } 136 + .recipe-source a{ 137 + color:var(--text); 138 + text-decoration:none; 139 + } 140 + .recipe-source a:hover{ 141 + color:var(--accent); 142 + text-decoration:underline; 143 + } 144 + .recipe-source a:hover .source-path{ 145 + color:var(--accent); 146 + } 147 + .source-path{ 148 + color:var(--text-muted); 112 149 } 113 150 114 151 .recipe-header{margin-bottom:1.5rem} 115 - .recipe-header h2{font-size:1.6rem;letter-spacing:-0.01em;margin-bottom:0.35rem} 152 + .recipe-header h2{font-size:2rem;line-height:1.3;letter-spacing:0;font-weight:600;margin-bottom:0.35rem} 116 153 .recipe-meta{color:var(--text-muted);font-size:0.9rem} 117 154 .recipe-meta span{margin-right:1rem} 118 155 .description{margin-top:0.5rem;color:var(--text-muted);font-size:0.95rem} ··· 122 159 padding:0.15rem 0.5rem; 123 160 border-radius:4px; 124 161 font-size:0.75rem; 125 - font-family:system-ui,sans-serif; 162 + font-family:'Poppins',system-ui,sans-serif; 126 163 vertical-align:middle; 127 164 } 128 165 .extraction-badge.schema{background:#dbeafe;color:#1e40af} ··· 138 175 border-bottom:2px solid var(--border); 139 176 padding-bottom:0.35rem; 140 177 margin-bottom:0.75rem; 141 - font-family:system-ui,sans-serif; 178 + font-family:'Poppins',system-ui,sans-serif; 142 179 } 143 180 144 181 .ingredient-list{list-style:none} ··· 163 200 .instruction-list li{ 164 201 counter-increment:step; 165 202 position:relative; 166 - padding-left:2.2rem; 167 - margin-bottom:0.75rem; 203 + padding-left:2rem; 204 + margin-bottom:1.25rem; 168 205 } 169 206 .instruction-list li::before{ 170 - content:counter(step); 207 + content:counter(step) "."; 171 208 position:absolute; 172 - left:0;top:0.15rem; 173 - width:1.6rem;height:1.6rem; 174 - background:var(--accent); 175 - color:#fff; 176 - border-radius:50%; 177 - font-size:0.8rem; 178 - font-family:system-ui,sans-serif; 179 - display:flex;align-items:center;justify-content:center; 209 + left:0;top:0; 210 + color:var(--text-muted); 211 + font-size:0.85rem; 212 + font-family:'Poppins',system-ui,sans-serif; 213 + font-weight:600; 180 214 } 181 215 216 + .timer-trigger{cursor:pointer} 217 + .timer-trigger:hover{text-decoration:underline} 218 + 219 + .timer-widget{ 220 + position:fixed; 221 + bottom:1.5rem; 222 + right:1.5rem; 223 + background:var(--surface); 224 + border:1px solid var(--border); 225 + border-radius:var(--radius); 226 + padding:1.25rem; 227 + box-shadow:0 4px 24px rgba(0,0,0,0.08); 228 + text-align:center; 229 + z-index:100; 230 + min-width:180px; 231 + } 232 + .timer-widget.hidden{display:none} 233 + .timer-close{ 234 + position:absolute; 235 + top:0.4rem; 236 + right:0.5rem; 237 + background:none; 238 + border:none; 239 + font-size:1.1rem; 240 + cursor:pointer; 241 + color:var(--text-muted); 242 + line-height:1; 243 + } 244 + .timer-close:hover{color:var(--text)} 245 + .timer-label{ 246 + font-family:'Poppins',system-ui,sans-serif; 247 + font-size:0.75rem; 248 + text-transform:uppercase; 249 + letter-spacing:0.08em; 250 + color:var(--text-muted); 251 + margin-bottom:0.5rem; 252 + } 253 + .timer-display{ 254 + font-family:'Poppins',system-ui,sans-serif; 255 + font-size:2.5rem; 256 + font-weight:700; 257 + letter-spacing:-0.02em; 258 + line-height:1; 259 + margin-bottom:0.75rem; 260 + } 261 + .timer-done{color:var(--accent);animation:pulse 1s ease-in-out infinite} 262 + @keyframes pulse{0%,100%{opacity:1}50%{opacity:0.4}} 263 + .timer-controls{ 264 + display:flex; 265 + gap:0.4rem; 266 + justify-content:center; 267 + } 268 + .timer-btn{ 269 + padding:0.35rem 0.75rem; 270 + border:1px solid var(--border); 271 + border-radius:4px; 272 + background:var(--surface); 273 + font-family:'Poppins',system-ui,sans-serif; 274 + font-size:0.75rem; 275 + cursor:pointer; 276 + } 277 + .timer-btn:hover{border-color:var(--accent)} 278 + 279 + .ing{font-weight:500} 280 + .tmr{font-weight:500} 281 + 182 282 .actions{ 183 283 display:flex; 184 284 gap:0.75rem; ··· 189 289 padding:0.6rem 1.2rem; 190 290 border-radius:var(--radius); 191 291 font-size:0.9rem; 192 - font-family:system-ui,sans-serif; 292 + font-family:'Poppins',system-ui,sans-serif; 193 293 cursor:pointer; 194 294 text-decoration:none; 195 295 transition:all 0.2s;
+1
ui/templates/base.html
··· 5 5 <meta charset="utf-8"> 6 6 <meta name="viewport" content="width=device-width,initial-scale=1"> 7 7 <title>{{block "title" .}}pare{{end}}</title> 8 + <link rel="icon" type="image/svg+xml" href="/static/favicon.svg"> 8 9 <link rel="stylesheet" href="/static/style.css"> 9 10 </head> 10 11 <body>
+3 -2
ui/templates/error.html
··· 5 5 <meta charset="utf-8"> 6 6 <meta name="viewport" content="width=device-width,initial-scale=1"> 7 7 <title>pare - error</title> 8 + <link rel="icon" type="image/svg+xml" href="/static/favicon.svg"> 8 9 <link rel="stylesheet" href="/static/style.css"> 9 10 </head> 10 11 <body> ··· 19 20 </div> 20 21 </div> 21 22 <footer> 22 - <span>made by <a href="https://dunkirk.sh">Kieran Klukas</a></span> 23 - <a href="https://tangled.org/dunkirk.sh/pare/commit/{{.GitHash}}" class="commit-link">{{.GitHash}}</a> 23 + <span>made by <a href="https://dunkirk.sh" target="_blank" rel="noopener">Kieran Klukas</a></span> 24 + <a href="https://tangled.org/dunkirk.sh/pare/commit/{{.GitHash}}" target="_blank" rel="noopener" class="commit-link">{{.GitHash}}</a> 24 25 </footer> 25 26 </body> 26 27 </html>
+3 -2
ui/templates/index.html
··· 5 5 <meta charset="utf-8"> 6 6 <meta name="viewport" content="width=device-width,initial-scale=1"> 7 7 <title>pare</title> 8 + <link rel="icon" type="image/svg+xml" href="/static/favicon.svg"> 8 9 <link rel="stylesheet" href="/static/style.css"> 9 10 </head> 10 11 <body> ··· 23 24 </div> 24 25 </div> 25 26 <footer> 26 - <span>made by <a href="https://dunkirk.sh">Kieran Klukas</a></span> 27 - <a href="https://tangled.org/dunkirk.sh/pare/commit/{{.GitHash}}" class="commit-link">{{.GitHash}}</a> 27 + <span>made by <a href="https://dunkirk.sh" target="_blank" rel="noopener">Kieran Klukas</a></span> 28 + <a href="https://tangled.org/dunkirk.sh/pare/commit/{{.GitHash}}" target="_blank" rel="noopener" class="commit-link">{{.GitHash}}</a> 28 29 </footer> 29 30 </body> 30 31 </html>
+111 -7
ui/templates/recipe.html
··· 5 5 <meta charset="utf-8"> 6 6 <meta name="viewport" content="width=device-width,initial-scale=1"> 7 7 <title>pare - {{.Recipe.Name}}</title> 8 + <link rel="icon" type="image/svg+xml" href="/static/favicon.svg"> 8 9 <link rel="stylesheet" href="/static/style.css"> 9 10 </head> 10 11 <body> ··· 13 14 </nav> 14 15 <div class="page"> 15 16 {{if .Recipe.ImageURL}} 16 - <img class="recipe-image" src="{{.Recipe.ImageURL}}" alt="{{.Recipe.Name}}" referrerpolicy="no-referrer"> 17 + <figure class="recipe-image-wrapper"> 18 + <img class="recipe-image" src="{{.Recipe.ImageURL}}" alt="{{.Recipe.Name}}" referrerpolicy="no-referrer"> 19 + <figcaption class="recipe-source"><a href="{{.Recipe.SourceURL}}" target="_blank" rel="noopener">{{with cleanSource .Recipe.SourceURL}}{{.host}}{{if .path}}<span class="source-path">{{.path}}</span>{{end}}{{end}}</a></figcaption> 20 + </figure> 17 21 {{end}} 18 22 19 23 <div class="recipe-header"> 20 24 <h2>{{.Recipe.Name}}</h2> 21 25 <div class="recipe-meta"> 22 - {{if .Recipe.PrepTime}}<span>&#9201; {{fmtDuration .Recipe.PrepTime}} prep</span>{{end}} 23 - {{if .Recipe.CookTime}}<span>&#9201; {{fmtDuration .Recipe.CookTime}} cook</span>{{end}} 26 + {{if .Recipe.PrepTime}}<span class="timer-trigger" data-seconds="{{isoToSeconds .Recipe.PrepTime}}" data-label="Prep">&#9201; {{fmtDuration .Recipe.PrepTime}} prep</span>{{end}} 27 + {{if .Recipe.CookTime}}<span class="timer-trigger" data-seconds="{{isoToSeconds .Recipe.CookTime}}" data-label="Cook">&#9201; {{fmtDuration .Recipe.CookTime}} cook</span>{{end}} 24 28 {{if .Recipe.Yield}}<span>Serves {{.Recipe.Yield}}</span>{{end}} 25 29 </div> 26 30 {{if .Recipe.Description}}<p class="description">{{.Recipe.Description}}</p>{{end}} ··· 45 49 <h3>Instructions</h3> 46 50 <ol class="instruction-list"> 47 51 {{range .Recipe.Instructions}} 48 - <li>{{.Text}}</li> 52 + <li>{{renderStep .Text $.Recipe.Ingredients}}</li> 49 53 {{end}} 50 54 </ol> 51 55 </div> ··· 53 57 54 58 <div class="actions"> 55 59 <a class="btn-primary" href="/export.cook?url={{.TargetURL | urlquery}}">Download .cook</a> 56 - <a class="btn-secondary" href="{{.Recipe.SourceURL}}" target="_blank" rel="noopener">{{.Recipe.SourceDomain}} &#8599;</a> 60 + </div> 61 + 62 + <div id="timer-widget" class="timer-widget hidden"> 63 + <button class="timer-close" onclick="closeTimer()">&times;</button> 64 + <div class="timer-label" id="timer-label"></div> 65 + <div class="timer-display" id="timer-display">00:00</div> 66 + <div class="timer-controls"> 67 + <button class="timer-btn" id="timer-start" onclick="startTimer()">Start</button> 68 + <button class="timer-btn" id="timer-pause" onclick="pauseTimer()" style="display:none">Pause</button> 69 + <button class="timer-btn" id="timer-reset" onclick="resetTimer()">Reset</button> 70 + </div> 57 71 </div> 58 72 59 73 <script> ··· 86 100 } 87 101 88 102 loadChecks(); 103 + 104 + // Timer 105 + let timerInterval = null; 106 + let timerRemaining = 0; 107 + let timerRunning = false; 108 + 109 + document.querySelectorAll('.timer-trigger').forEach(el => { 110 + el.addEventListener('click', () => { 111 + const secs = parseInt(el.dataset.seconds, 10); 112 + const label = el.dataset.label; 113 + if (secs > 0) { 114 + openTimer(secs, label); 115 + } 116 + }); 117 + }); 118 + 119 + function openTimer(secs, label) { 120 + timerRemaining = secs; 121 + timerRunning = false; 122 + clearInterval(timerInterval); 123 + timerInterval = null; 124 + document.getElementById('timer-label').textContent = label; 125 + updateTimerDisplay(); 126 + document.getElementById('timer-start').style.display = ''; 127 + document.getElementById('timer-pause').style.display = 'none'; 128 + document.getElementById('timer-widget').classList.remove('hidden'); 129 + } 130 + 131 + function startTimer() { 132 + if (timerRunning) return; 133 + timerRunning = true; 134 + document.getElementById('timer-start').style.display = 'none'; 135 + document.getElementById('timer-pause').style.display = ''; 136 + timerInterval = setInterval(() => { 137 + timerRemaining--; 138 + updateTimerDisplay(); 139 + if (timerRemaining <= 0) { 140 + clearInterval(timerInterval); 141 + timerInterval = null; 142 + timerRunning = false; 143 + document.getElementById('timer-display').classList.add('timer-done'); 144 + if ('Notification' in window && Notification.permission === 'granted') { 145 + new Notification('Timer done!', { body: document.getElementById('timer-label').textContent + ' is done!' }); 146 + } 147 + } 148 + }, 1000); 149 + } 150 + 151 + function pauseTimer() { 152 + timerRunning = false; 153 + clearInterval(timerInterval); 154 + timerInterval = null; 155 + document.getElementById('timer-start').style.display = ''; 156 + document.getElementById('timer-pause').style.display = 'none'; 157 + } 158 + 159 + function resetTimer() { 160 + clearInterval(timerInterval); 161 + timerInterval = null; 162 + timerRunning = false; 163 + const triggers = document.querySelectorAll('.timer-trigger'); 164 + if (triggers.length > 0) { 165 + const active = [...triggers].find(t => t.dataset.label === document.getElementById('timer-label').textContent); 166 + if (active) { 167 + timerRemaining = parseInt(active.dataset.seconds, 10); 168 + } 169 + } 170 + document.getElementById('timer-start').style.display = ''; 171 + document.getElementById('timer-pause').style.display = 'none'; 172 + document.getElementById('timer-display').classList.remove('timer-done'); 173 + updateTimerDisplay(); 174 + } 175 + 176 + function closeTimer() { 177 + clearInterval(timerInterval); 178 + timerInterval = null; 179 + timerRunning = false; 180 + document.getElementById('timer-widget').classList.add('hidden'); 181 + } 182 + 183 + function updateTimerDisplay() { 184 + const m = Math.floor(timerRemaining / 60); 185 + const s = timerRemaining % 60; 186 + document.getElementById('timer-display').textContent = 187 + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0'); 188 + } 189 + 190 + if ('Notification' in window && Notification.permission === 'default') { 191 + Notification.requestPermission(); 192 + } 89 193 </script> 90 194 </div> 91 195 <footer> 92 - <span>made by <a href="https://dunkirk.sh">Kieran Klukas</a></span> 93 - <a href="https://tangled.org/dunkirk.sh/pare/commit/{{.GitHash}}" class="commit-link">{{.GitHash}}</a> 196 + <span>made by <a href="https://dunkirk.sh" target="_blank" rel="noopener">Kieran Klukas</a></span> 197 + <a href="https://tangled.org/dunkirk.sh/pare/commit/{{.GitHash}}" target="_blank" rel="noopener" class="commit-link">{{.GitHash}}</a> 94 198 </footer> 95 199 </body> 96 200 </html>