Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee
17
fork

Configure Feed

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

fix: fix alpine issues on other user's profiles

pdewey c62eaf51 9b633dd6

+375 -28
+90
.github/DEVELOPMENT_WORKFLOW.md
··· 1 + # Development Workflow 2 + 3 + ## Setting Up Firehose Feed with Known DIDs 4 + 5 + For development and testing, you can populate your local feed with known Arabica users: 6 + 7 + ### 1. Create a Known DIDs File 8 + 9 + Create `known-dids.txt` in the project root: 10 + 11 + ```bash 12 + cat > known-dids.txt << 'EOF' 13 + # Known Arabica users for development 14 + # Add one DID per line 15 + 16 + # Example (replace with real DIDs): 17 + # did:plc:abc123xyz 18 + # did:plc:def456uvw 19 + 20 + EOF 21 + ``` 22 + 23 + ### 2. Find DIDs to Add 24 + 25 + You can find DIDs of Arabica users in several ways: 26 + 27 + **From Bluesky profiles:** 28 + - Visit a user's profile on Bluesky 29 + - Check the URL or profile metadata for their DID 30 + 31 + **From authenticated sessions:** 32 + - After logging into Arabica, check your browser cookies 33 + - The `did` cookie contains your DID 34 + 35 + **From AT Protocol explorer tools:** 36 + - Use tools like `atproto.blue` to search for users 37 + 38 + ### 3. Run Server with Backfill 39 + 40 + ```bash 41 + # Start server with firehose and backfill 42 + go run cmd/server/main.go --firehose --known-dids known-dids.txt 43 + 44 + # Or with nix (requires adding flags to flake.nix) 45 + nix run -- --firehose --known-dids known-dids.txt 46 + ``` 47 + 48 + ### 4. Monitor Backfill Progress 49 + 50 + Watch the logs for backfill activity: 51 + 52 + ``` 53 + {"level":"info","count":3,"file":"known-dids.txt","message":"Loaded known DIDs from file"} 54 + {"level":"info","did":"did:plc:abc123xyz","message":"backfilling user records"} 55 + {"level":"info","total":5,"success":5,"message":"Backfill complete"} 56 + ``` 57 + 58 + ### 5. Verify Feed Data 59 + 60 + Once backfilled, check: 61 + - Home page feed should show brews from backfilled users 62 + - `/feed` endpoint should return feed items 63 + - Database should contain indexed records 64 + 65 + ## File Format Notes 66 + 67 + The `known-dids.txt` file supports: 68 + 69 + - **Comments**: Lines starting with `#` 70 + - **Empty lines**: Ignored 71 + - **Whitespace**: Automatically trimmed 72 + - **Validation**: Non-DID lines logged as warnings 73 + 74 + Example valid file: 75 + 76 + ``` 77 + # Coffee enthusiasts to follow 78 + did:plc:user1abc 79 + 80 + # Another user 81 + did:plc:user2def 82 + 83 + did:web:coffee.example.com # Web DID example 84 + ``` 85 + 86 + ## Security Note 87 + 88 + ⚠️ **Important**: The `known-dids.txt` file is gitignored by default. Do not commit DIDs unless you have permission from the users. 89 + 90 + For production deployments, rely on organic discovery via firehose rather than manual DID lists.
+3
.gitignore
··· 45 45 46 46 # Other 47 47 *.bak 48 + 49 + # Development files 50 + known-dids.txt
+8 -5
BACKLOG.md
··· 24 24 - If adding mobile apps, third-party API consumers, or microservices architecture, revisit this 25 25 - For now, monolithic approach is appropriate for HTMX-based web app with decentralized storage 26 26 27 + - Backfill seems to be called when user hits homepage, probably only needs to be done on startup 28 + 27 29 ## Fixes 28 30 29 - - [Future work]: adjust timing of caching in feed, maybe use firehose and a sqlite database since we are only storing a few anyway 30 - - Goal: reduce pings to server when idling 31 + - After adding a bean via add brew, that bean does not show up in the drop down until after a refresh 32 + - Happens with grinders and likely brewers also 31 33 32 - - Non-authed home page feed shows different from what the firehose version shows (should be cached and show same db contents I think) 33 - 34 - - After adding a bean via add brew, that bean does not show up in the drop down until after a refresh 34 + - Adding a grinder via the new brew page does not populate fields correctly other than the name 35 + - Also seems to happen to brewers 36 + - To solve this issue and the above, we likely should consolidate creation to use the same popup as the manage page uses, 37 + since that one works, and should already be a template partial.
+32 -9
CLAUDE.md
··· 100 100 ### Run Development Server 101 101 102 102 ```bash 103 + # Basic mode (polling-based feed) 103 104 go run cmd/server/main.go 104 - # or 105 + 106 + # With firehose (real-time AT Protocol feed) 107 + go run cmd/server/main.go --firehose 108 + 109 + # With firehose + backfill known DIDs 110 + go run cmd/server/main.go --firehose --known-dids known-dids.txt 111 + 112 + # Using nix 105 113 nix run 106 114 ``` 107 115 ··· 117 125 go build -o arabica cmd/server/main.go 118 126 ``` 119 127 128 + ## Command-Line Flags 129 + 130 + | Flag | Type | Default | Description | 131 + | --------------- | ------ | ------- | ----------------------------------------------------- | 132 + | `--firehose` | bool | false | Enable real-time firehose feed via Jetstream | 133 + | `--known-dids` | string | "" | Path to file with DIDs to backfill (one per line) | 134 + 135 + **Known DIDs File Format:** 136 + - One DID per line (e.g., `did:plc:abc123xyz`) 137 + - Lines starting with `#` are comments 138 + - Empty lines are ignored 139 + - See `known-dids.txt.example` for reference 140 + 120 141 ## Environment Variables 121 142 122 - | Variable | Default | Description | 123 - | ------------------- | --------------------------------- | ---------------------------- | 124 - | `PORT` | 18910 | HTTP server port | 125 - | `SERVER_PUBLIC_URL` | - | Public URL for reverse proxy | 126 - | `ARABICA_DB_PATH` | ~/.local/share/arabica/arabica.db | BoltDB path | 127 - | `SECURE_COOKIES` | false | Set true for HTTPS | 128 - | `LOG_LEVEL` | info | debug/info/warn/error | 129 - | `LOG_FORMAT` | console | console/json | 143 + | Variable | Default | Description | 144 + | --------------------------- | --------------------------------- | ---------------------------------- | 145 + | `PORT` | 18910 | HTTP server port | 146 + | `SERVER_PUBLIC_URL` | - | Public URL for reverse proxy | 147 + | `ARABICA_DB_PATH` | ~/.local/share/arabica/arabica.db | BoltDB path (sessions, registry) | 148 + | `ARABICA_FEED_INDEX_PATH` | ~/.local/share/arabica/feed-index.db | Firehose index BoltDB path | 149 + | `ARABICA_PROFILE_CACHE_TTL` | 1h | Profile cache duration | 150 + | `SECURE_COOKIES` | false | Set true for HTTPS | 151 + | `LOG_LEVEL` | info | debug/info/warn/error | 152 + | `LOG_FORMAT` | console | console/json | 130 153 131 154 ## Code Patterns 132 155
+30 -2
README.md
··· 43 43 44 44 ## Configuration 45 45 46 - Environment variables: 46 + ### Command-Line Flags 47 + 48 + - `--firehose` - Enable real-time feed via AT Protocol Jetstream (default: false) 49 + - `--known-dids <file>` - Path to file with DIDs to backfill on startup (one per line) 50 + 51 + ### Environment Variables 47 52 48 53 - `PORT` - Server port (default: 18910) 49 54 - `SERVER_PUBLIC_URL` - Public URL for reverse proxy deployments (e.g., https://arabica.example.com) 50 55 - `ARABICA_DB_PATH` - BoltDB path (default: ~/.local/share/arabica/arabica.db) 56 + - `ARABICA_FEED_INDEX_PATH` - Firehose index BoltDB path (default: ~/.local/share/arabica/feed-index.db) 57 + - `ARABICA_PROFILE_CACHE_TTL` - Profile cache duration (default: 1h) 51 58 - `OAUTH_CLIENT_ID` - OAuth client ID (optional, uses localhost mode if not set) 52 59 - `OAUTH_REDIRECT_URI` - OAuth redirect URI (optional) 53 60 - `SECURE_COOKIES` - Set to true for HTTPS (default: false) ··· 58 65 59 66 - Track coffee brews with detailed parameters 60 67 - Store data in your AT Protocol Personal Data Server 61 - - Community feed of recent brews from registered users 68 + - Community feed of recent brews from registered users (polling or real-time firehose) 62 69 - Manage beans, roasters, grinders, and brewers 63 70 - Export brew data as JSON 64 71 - Mobile-friendly PWA design 72 + 73 + ### Firehose Mode 74 + 75 + Enable real-time feed updates via AT Protocol's Jetstream: 76 + 77 + ```bash 78 + # Basic firehose mode 79 + go run cmd/server/main.go --firehose 80 + 81 + # With known DIDs for backfill 82 + go run cmd/server/main.go --firehose --known-dids known-dids.txt 83 + ``` 84 + 85 + **Known DIDs file format:** 86 + ``` 87 + # Comments start with # 88 + did:plc:abc123xyz 89 + did:plc:def456uvw 90 + ``` 91 + 92 + The firehose automatically indexes **all** Arabica records across the AT Protocol network. The `--known-dids` flag allows you to backfill historical records from specific users on startup (useful for development/testing). 65 93 66 94 ## Architecture 67 95
+74 -2
cmd/server/main.go
··· 1 1 package main 2 2 3 3 import ( 4 + "bufio" 4 5 "context" 5 6 "flag" 6 7 "fmt" ··· 8 9 "os" 9 10 "os/signal" 10 11 "path/filepath" 12 + "strings" 11 13 "syscall" 12 14 "time" 13 15 ··· 25 27 func main() { 26 28 // Parse command-line flags 27 29 useFirehose := flag.Bool("firehose", false, "Enable firehose-based feed (Jetstream consumer)") 30 + knownDIDsFile := flag.String("known-dids", "", "Path to file containing DIDs to backfill on startup (one per line)") 28 31 flag.Parse() 29 32 30 33 // Configure zerolog ··· 186 189 187 190 log.Info().Msg("Firehose consumer started") 188 191 189 - // Backfill registered users in background 192 + // Backfill registered users and known DIDs in background 190 193 go func() { 191 194 time.Sleep(5 * time.Second) // Wait for initial connection 195 + 196 + // Collect all DIDs to backfill 197 + didsToBackfill := make(map[string]struct{}) 198 + 199 + // Add registered users 192 200 for _, did := range feedRegistry.List() { 201 + didsToBackfill[did] = struct{}{} 202 + } 203 + 204 + // Add DIDs from known-dids file if provided 205 + if *knownDIDsFile != "" { 206 + knownDIDs, err := loadKnownDIDs(*knownDIDsFile) 207 + if err != nil { 208 + log.Warn().Err(err).Str("file", *knownDIDsFile).Msg("Failed to load known DIDs file") 209 + } else { 210 + for _, did := range knownDIDs { 211 + didsToBackfill[did] = struct{}{} 212 + } 213 + log.Info().Int("count", len(knownDIDs)).Str("file", *knownDIDsFile).Msg("Loaded known DIDs from file") 214 + } 215 + } 216 + 217 + // Backfill all collected DIDs 218 + successCount := 0 219 + for did := range didsToBackfill { 193 220 if err := firehoseConsumer.BackfillDID(ctx, did); err != nil { 194 221 log.Warn().Err(err).Str("did", did).Msg("Failed to backfill user") 222 + } else { 223 + successCount++ 195 224 } 196 225 } 197 - log.Info().Int("count", feedRegistry.Count()).Msg("Backfill of registered users complete") 226 + log.Info().Int("total", len(didsToBackfill)).Int("success", successCount).Msg("Backfill complete") 198 227 }() 199 228 } 200 229 ··· 299 328 300 329 log.Info().Msg("Server stopped") 301 330 } 331 + 332 + // loadKnownDIDs reads a file containing DIDs (one per line) and returns them as a slice. 333 + // Lines starting with # are treated as comments and ignored. 334 + // Empty lines and whitespace are trimmed. 335 + func loadKnownDIDs(filePath string) ([]string, error) { 336 + file, err := os.Open(filePath) 337 + if err != nil { 338 + return nil, fmt.Errorf("failed to open file: %w", err) 339 + } 340 + defer file.Close() 341 + 342 + var dids []string 343 + scanner := bufio.NewScanner(file) 344 + lineNum := 0 345 + 346 + for scanner.Scan() { 347 + lineNum++ 348 + line := strings.TrimSpace(scanner.Text()) 349 + 350 + // Skip empty lines and comments 351 + if line == "" || strings.HasPrefix(line, "#") { 352 + continue 353 + } 354 + 355 + // Basic DID validation (must start with "did:") 356 + if !strings.HasPrefix(line, "did:") { 357 + log.Warn(). 358 + Str("file", filePath). 359 + Int("line", lineNum). 360 + Str("content", line). 361 + Msg("Skipping invalid DID (must start with 'did:')") 362 + continue 363 + } 364 + 365 + dids = append(dids, line) 366 + } 367 + 368 + if err := scanner.Err(); err != nil { 369 + return nil, fmt.Errorf("error reading file: %w", err) 370 + } 371 + 372 + return dids, nil 373 + }
+110
cmd/server/main_test.go
··· 1 + package main 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "testing" 7 + ) 8 + 9 + func TestLoadKnownDIDs(t *testing.T) { 10 + // Create a temporary test file 11 + tmpDir := t.TempDir() 12 + testFile := filepath.Join(tmpDir, "test-dids.txt") 13 + 14 + content := `# This is a comment 15 + did:plc:test123abc 16 + did:web:example.com 17 + 18 + # Another comment 19 + 20 + did:plc:another456def 21 + 22 + # Invalid lines below 23 + not-a-did 24 + just some text 25 + 26 + # Valid DID after invalid ones 27 + did:plc:final789ghi 28 + ` 29 + 30 + if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { 31 + t.Fatalf("Failed to create test file: %v", err) 32 + } 33 + 34 + // Test loading DIDs 35 + dids, err := loadKnownDIDs(testFile) 36 + if err != nil { 37 + t.Fatalf("loadKnownDIDs failed: %v", err) 38 + } 39 + 40 + // Expected DIDs 41 + expected := []string{ 42 + "did:plc:test123abc", 43 + "did:web:example.com", 44 + "did:plc:another456def", 45 + "did:plc:final789ghi", 46 + } 47 + 48 + if len(dids) != len(expected) { 49 + t.Errorf("Expected %d DIDs, got %d", len(expected), len(dids)) 50 + } 51 + 52 + for i, expectedDID := range expected { 53 + if i >= len(dids) { 54 + t.Errorf("Missing DID at index %d: %s", i, expectedDID) 55 + continue 56 + } 57 + if dids[i] != expectedDID { 58 + t.Errorf("DID at index %d: expected %s, got %s", i, expectedDID, dids[i]) 59 + } 60 + } 61 + } 62 + 63 + func TestLoadKnownDIDs_EmptyFile(t *testing.T) { 64 + tmpDir := t.TempDir() 65 + testFile := filepath.Join(tmpDir, "empty.txt") 66 + 67 + if err := os.WriteFile(testFile, []byte(""), 0644); err != nil { 68 + t.Fatalf("Failed to create test file: %v", err) 69 + } 70 + 71 + dids, err := loadKnownDIDs(testFile) 72 + if err != nil { 73 + t.Fatalf("loadKnownDIDs failed: %v", err) 74 + } 75 + 76 + if len(dids) != 0 { 77 + t.Errorf("Expected 0 DIDs from empty file, got %d", len(dids)) 78 + } 79 + } 80 + 81 + func TestLoadKnownDIDs_OnlyComments(t *testing.T) { 82 + tmpDir := t.TempDir() 83 + testFile := filepath.Join(tmpDir, "comments.txt") 84 + 85 + content := `# Comment 1 86 + # Comment 2 87 + 88 + # Comment 3 89 + ` 90 + 91 + if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { 92 + t.Fatalf("Failed to create test file: %v", err) 93 + } 94 + 95 + dids, err := loadKnownDIDs(testFile) 96 + if err != nil { 97 + t.Fatalf("loadKnownDIDs failed: %v", err) 98 + } 99 + 100 + if len(dids) != 0 { 101 + t.Errorf("Expected 0 DIDs from comments-only file, got %d", len(dids)) 102 + } 103 + } 104 + 105 + func TestLoadKnownDIDs_NonexistentFile(t *testing.T) { 106 + _, err := loadKnownDIDs("/nonexistent/path/file.txt") 107 + if err == nil { 108 + t.Error("Expected error for nonexistent file, got nil") 109 + } 110 + }
+1 -1
internal/bff/__snapshots__/complete_brew_with_all_fields.snap
··· 4 4 file_name: feed_template_snapshot_test.go 5 5 version: 0.1.0 6 6 --- 7 - "\n<div class=\"space-y-4\">\n \n \n <div class=\"bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow\">\n \n <div class=\"flex items-center gap-3 mb-3\">\n <a href=\"/profile/coffee.lover\" class=\"flex-shrink-0\">\n \n \n \n <img src=\"https://cdn.bsky.app/avatar.jpg\" alt=\"\" class=\"w-10 h-10 rounded-full object-cover hover:ring-2 hover:ring-brown-600 transition\" />\n \n \n </a>\n <div class=\"flex-1 min-w-0\">\n <div class=\"flex items-center gap-2\">\n \n <a href=\"/profile/coffee.lover\" class=\"font-medium text-brown-900 truncate hover:text-brown-700 hover:underline\">Coffee Enthusiast</a>\n \n <a href=\"/profile/coffee.lover\" class=\"text-brown-600 text-sm truncate hover:text-brown-700 hover:underline\">@coffee.lover</a>\n </div>\n <span class=\"text-brown-500 text-sm\">2 hours ago</span>\n </div>\n </div>\n\n \n <div class=\"mb-2 text-sm text-brown-700\">\n ☕ added a new brew\n </div>\n\n \n \n \n <div class=\"bg-white/60 backdrop-blur rounded-lg p-4 border border-brown-200\">\n \n <div class=\"flex items-start justify-between gap-3 mb-3\">\n <div class=\"flex-1 min-w-0\">\n \n <div class=\"font-bold text-brown-900 text-base\">\n Ethiopian Yirgacheffe\n </div>\n \n <div class=\"text-sm text-brown-700 mt-0.5\">\n <span class=\"font-medium\">🏪 Onyx Coffee Lab</span>\n </div>\n \n <div class=\"text-xs text-brown-600 mt-1 flex flex-wrap gap-x-2 gap-y-0.5\">\n <span class=\"inline-flex items-center gap-0.5\">📍 Ethiopia</span>\n <span class=\"inline-flex items-center gap-0.5\">🔥 Light</span>\n <span class=\"inline-flex items-center gap-0.5\">🌱 Washed</span>\n <span class=\"inline-flex items-center gap-0.5\">⚖️ 16g</span>\n </div>\n \n </div>\n \n <span class=\"inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-900 flex-shrink-0\">\n ⭐ 9/10\n </span>\n \n </div>\n \n \n \n <div class=\"mb-2\">\n <span class=\"text-xs text-brown-600\">Brewer:</span>\n <span class=\"text-sm font-semibold text-brown-900\">\n Hario V60\n </span>\n </div>\n \n \n \n <div class=\"grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-brown-700\">\n \n <div>\n <span class=\"text-brown-600\">Grinder:</span> 1Zpresso JX-Pro (Medium-fine)\n </div>\n \n \n <div class=\"col-span-2\">\n <span class=\"text-brown-600\">Pours:</span>\n \n <div class=\"pl-2 text-brown-600\">• 50g @ 30s</div>\n \n <div class=\"pl-2 text-brown-600\">• 100g @ 45s</div>\n \n <div class=\"pl-2 text-brown-600\">• 100g @ 1m</div>\n \n </div>\n \n \n <div>\n <span class=\"text-brown-600\">Temp:</span> 93.0°C\n </div>\n \n \n <div>\n <span class=\"text-brown-600\">Time:</span> 3m\n </div>\n \n </div>\n\n \n <div class=\"mt-3 text-sm text-brown-800 italic border-t border-brown-200 pt-2\">\n \"Bright citrus notes with floral aroma\"\n </div>\n \n </div>\n \n </div>\n \n \n \n</div>\n" 7 + "\n<div class=\"space-y-4\">\n \n \n <div class=\"bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow\">\n \n <div class=\"flex items-center gap-3 mb-3\">\n <a href=\"/profile/coffee.lover\" class=\"flex-shrink-0\">\n \n \n \n <img src=\"https://cdn.bsky.app/avatar.jpg\" alt=\"\" class=\"w-10 h-10 rounded-full object-cover hover:ring-2 hover:ring-brown-600 transition\" />\n \n \n </a>\n <div class=\"flex-1 min-w-0\">\n <div class=\"flex items-center gap-2\">\n \n <a href=\"/profile/coffee.lover\" class=\"font-medium text-brown-900 truncate hover:text-brown-700 hover:underline\">Coffee Enthusiast</a>\n \n <a href=\"/profile/coffee.lover\" class=\"text-brown-600 text-sm truncate hover:text-brown-700 hover:underline\">@coffee.lover</a>\n </div>\n <span class=\"text-brown-500 text-sm\">2 hours ago</span>\n </div>\n </div>\n\n \n <div class=\"mb-2 text-sm text-brown-700\">\n ☕ added a new brew\n </div>\n\n \n \n \n <div class=\"bg-white/60 backdrop-blur rounded-lg p-4 border border-brown-200\">\n \n <div class=\"flex items-start justify-between gap-3 mb-3\">\n <div class=\"flex-1 min-w-0\">\n \n <div class=\"font-bold text-brown-900 text-base\">\n Ethiopian Yirgacheffe\n </div>\n \n <div class=\"text-sm text-brown-700 mt-0.5\">\n <span class=\"font-medium\">🏭 Onyx Coffee Lab</span>\n </div>\n \n <div class=\"text-xs text-brown-600 mt-1 flex flex-wrap gap-x-2 gap-y-0.5\">\n <span class=\"inline-flex items-center gap-0.5\">📍 Ethiopia</span>\n <span class=\"inline-flex items-center gap-0.5\">🔥 Light</span>\n <span class=\"inline-flex items-center gap-0.5\">🌱 Washed</span>\n <span class=\"inline-flex items-center gap-0.5\">⚖️ 16g</span>\n </div>\n \n </div>\n \n <span class=\"inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-900 flex-shrink-0\">\n ⭐ 9/10\n </span>\n \n </div>\n \n \n \n <div class=\"mb-2\">\n <span class=\"text-xs text-brown-600\">Brewer:</span>\n <span class=\"text-sm font-semibold text-brown-900\">\n Hario V60\n </span>\n </div>\n \n \n \n <div class=\"grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-brown-700\">\n \n <div>\n <span class=\"text-brown-600\">Grinder:</span> 1Zpresso JX-Pro (Medium-fine)\n </div>\n \n \n <div class=\"col-span-2\">\n <span class=\"text-brown-600\">Pours:</span>\n \n <div class=\"pl-2 text-brown-600\">• 50g @ 30s</div>\n \n <div class=\"pl-2 text-brown-600\">• 100g @ 45s</div>\n \n <div class=\"pl-2 text-brown-600\">• 100g @ 1m</div>\n \n </div>\n \n \n <div>\n <span class=\"text-brown-600\">Temp:</span> 93.0°C\n </div>\n \n \n <div>\n <span class=\"text-brown-600\">Time:</span> 3m\n </div>\n \n </div>\n\n \n <div class=\"mt-3 text-sm text-brown-800 italic border-t border-brown-200 pt-2\">\n \"Bright citrus notes with floral aroma\"\n </div>\n \n </div>\n \n </div>\n \n \n \n</div>\n"
+1 -1
internal/bff/__snapshots__/mixed_feed_all_types.snap
··· 4 4 file_name: feed_template_snapshot_test.go 5 5 version: 0.1.0 6 6 --- 7 - "\n<div class=\"space-y-4\">\n \n \n <div class=\"bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow\">\n \n <div class=\"flex items-center gap-3 mb-3\">\n <a href=\"/profile/user1\" class=\"flex-shrink-0\">\n \n <div class=\"w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition\">\n <span class=\"text-brown-600 text-sm\">?</span>\n </div>\n \n </a>\n <div class=\"flex-1 min-w-0\">\n <div class=\"flex items-center gap-2\">\n \n <a href=\"/profile/user1\" class=\"font-medium text-brown-900 truncate hover:text-brown-700 hover:underline\">User One</a>\n \n <a href=\"/profile/user1\" class=\"text-brown-600 text-sm truncate hover:text-brown-700 hover:underline\">@user1</a>\n </div>\n <span class=\"text-brown-500 text-sm\">1 hour ago</span>\n </div>\n </div>\n\n \n <div class=\"mb-2 text-sm text-brown-700\">\n ☕ added a new brew\n </div>\n\n \n \n \n <div class=\"bg-white/60 backdrop-blur rounded-lg p-4 border border-brown-200\">\n \n <div class=\"flex items-start justify-between gap-3 mb-3\">\n <div class=\"flex-1 min-w-0\">\n \n <div class=\"font-bold text-brown-900 text-base\">\n Ethiopian Yirgacheffe\n </div>\n \n <div class=\"text-sm text-brown-700 mt-0.5\">\n <span class=\"font-medium\">🏪 Onyx</span>\n </div>\n \n <div class=\"text-xs text-brown-600 mt-1 flex flex-wrap gap-x-2 gap-y-0.5\">\n <span class=\"inline-flex items-center gap-0.5\">📍 Ethiopia</span>\n <span class=\"inline-flex items-center gap-0.5\">🔥 Light</span>\n <span class=\"inline-flex items-center gap-0.5\">🌱 Washed</span>\n <span class=\"inline-flex items-center gap-0.5\">⚖️ 16g</span>\n </div>\n \n </div>\n \n <span class=\"inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-900 flex-shrink-0\">\n ⭐ 9/10\n </span>\n \n </div>\n \n \n \n <div class=\"mb-2\">\n <span class=\"text-xs text-brown-600\">Brewer:</span>\n <span class=\"text-sm font-semibold text-brown-900\">\n Hario V60\n </span>\n </div>\n \n \n \n <div class=\"grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-brown-700\">\n \n <div>\n <span class=\"text-brown-600\">Grinder:</span> 1Zpresso JX-Pro (Medium-fine)\n </div>\n \n \n <div class=\"col-span-2\">\n <span class=\"text-brown-600\">Pours:</span>\n \n <div class=\"pl-2 text-brown-600\">• 50g @ 30s</div>\n \n <div class=\"pl-2 text-brown-600\">• 100g @ 45s</div>\n \n <div class=\"pl-2 text-brown-600\">• 100g @ 1m</div>\n \n </div>\n \n \n <div>\n <span class=\"text-brown-600\">Temp:</span> 93.0°C\n </div>\n \n \n <div>\n <span class=\"text-brown-600\">Time:</span> 3m\n </div>\n \n </div>\n\n \n <div class=\"mt-3 text-sm text-brown-800 italic border-t border-brown-200 pt-2\">\n \"Bright citrus notes with floral aroma\"\n </div>\n \n </div>\n \n </div>\n \n <div class=\"bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow\">\n \n <div class=\"flex items-center gap-3 mb-3\">\n <a href=\"/profile/user2\" class=\"flex-shrink-0\">\n \n \n \n <img src=\"https://cdn.bsky.app/avatar.jpg\" alt=\"\" class=\"w-10 h-10 rounded-full object-cover hover:ring-2 hover:ring-brown-600 transition\" />\n \n \n </a>\n <div class=\"flex-1 min-w-0\">\n <div class=\"flex items-center gap-2\">\n \n <a href=\"/profile/user2\" class=\"font-medium text-brown-900 truncate hover:text-brown-700 hover:underline\">User Two</a>\n \n <a href=\"/profile/user2\" class=\"text-brown-600 text-sm truncate hover:text-brown-700 hover:underline\">@user2</a>\n </div>\n <span class=\"text-brown-500 text-sm\">1.5 hours ago</span>\n </div>\n </div>\n\n \n <div class=\"mb-2 text-sm text-brown-700\">\n 🫘 added a new bean\n </div>\n\n \n \n \n <div class=\"bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200\">\n <div class=\"text-base mb-2\">\n <span class=\"font-bold text-brown-900\">\n Kenya AA\n </span>\n \n <span class=\"text-brown-700\"> from Onyx Coffee Lab</span>\n \n </div>\n <div class=\"text-sm text-brown-700 space-y-1\">\n \n <div><span class=\"text-brown-600\">Origin:</span> Kenya</div>\n \n \n <div><span class=\"text-brown-600\">Roast:</span> Medium</div>\n \n \n <div><span class=\"text-brown-600\">Process:</span> Natural</div>\n \n \n <div class=\"mt-2 text-brown-800 italic\">\"Sweet and fruity with notes of blueberry\"</div>\n \n </div>\n </div>\n \n </div>\n \n <div class=\"bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow\">\n \n <div class=\"flex items-center gap-3 mb-3\">\n <a href=\"/profile/user3\" class=\"flex-shrink-0\">\n \n <div class=\"w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition\">\n <span class=\"text-brown-600 text-sm\">?</span>\n </div>\n \n </a>\n <div class=\"flex-1 min-w-0\">\n <div class=\"flex items-center gap-2\">\n \n <a href=\"/profile/user3\" class=\"font-medium text-brown-900 truncate hover:text-brown-700 hover:underline\">User Three</a>\n \n <a href=\"/profile/user3\" class=\"text-brown-600 text-sm truncate hover:text-brown-700 hover:underline\">@user3</a>\n </div>\n <span class=\"text-brown-500 text-sm\">2 hours ago</span>\n </div>\n </div>\n\n \n <div class=\"mb-2 text-sm text-brown-700\">\n 🏪 added a new roaster\n </div>\n\n \n \n \n <div class=\"bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200\">\n <div class=\"text-base mb-2\">\n <span class=\"font-bold text-brown-900\">Heart Coffee Roasters</span>\n </div>\n <div class=\"text-sm text-brown-700 space-y-1\">\n \n <div><span class=\"text-brown-600\">Location:</span> Portland, OR</div>\n \n \n \n \n <div><span class=\"text-brown-600\">Website:</span> <a href=\"https://heartroasters.com\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-brown-800 hover:underline\">https://heartroasters.com</a></div>\n \n \n </div>\n </div>\n \n </div>\n \n <div class=\"bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow\">\n \n <div class=\"flex items-center gap-3 mb-3\">\n <a href=\"/profile/user4\" class=\"flex-shrink-0\">\n \n <div class=\"w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition\">\n <span class=\"text-brown-600 text-sm\">?</span>\n </div>\n \n </a>\n <div class=\"flex-1 min-w-0\">\n <div class=\"flex items-center gap-2\">\n \n <a href=\"/profile/user4\" class=\"text-brown-600 text-sm truncate hover:text-brown-700 hover:underline\">@user4</a>\n </div>\n <span class=\"text-brown-500 text-sm\">2.5 hours ago</span>\n </div>\n </div>\n\n \n <div class=\"mb-2 text-sm text-brown-700\">\n ⚙️ added a new grinder\n </div>\n\n \n \n \n <div class=\"bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200\">\n <div class=\"text-base mb-2\">\n <span class=\"font-bold text-brown-900\">Comandante C40</span>\n </div>\n <div class=\"text-sm text-brown-700 space-y-1\">\n \n <div><span class=\"text-brown-600\">Type:</span> Hand</div>\n \n \n <div><span class=\"text-brown-600\">Burr:</span> Conical</div>\n \n \n <div class=\"mt-2 text-brown-800 italic\">\"Excellent for pour over\"</div>\n \n </div>\n </div>\n \n </div>\n \n <div class=\"bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow\">\n \n <div class=\"flex items-center gap-3 mb-3\">\n <a href=\"/profile/user5\" class=\"flex-shrink-0\">\n \n <div class=\"w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition\">\n <span class=\"text-brown-600 text-sm\">?</span>\n </div>\n \n </a>\n <div class=\"flex-1 min-w-0\">\n <div class=\"flex items-center gap-2\">\n \n <a href=\"/profile/user5\" class=\"font-medium text-brown-900 truncate hover:text-brown-700 hover:underline\">User Five</a>\n \n <a href=\"/profile/user5\" class=\"text-brown-600 text-sm truncate hover:text-brown-700 hover:underline\">@user5</a>\n </div>\n <span class=\"text-brown-500 text-sm\">3 hours ago</span>\n </div>\n </div>\n\n \n <div class=\"mb-2 text-sm text-brown-700\">\n ☕ added a new brewer\n </div>\n\n \n \n \n <div class=\"bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200\">\n <div class=\"text-base mb-2\">\n <span class=\"font-bold text-brown-900\">Kalita Wave 185</span>\n </div>\n \n <div class=\"text-sm text-brown-800 italic\">\"Flat-bottom dripper with wave filters\"</div>\n \n </div>\n \n </div>\n \n \n \n</div>\n" 7 + "\n<div class=\"space-y-4\">\n \n \n <div class=\"bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow\">\n \n <div class=\"flex items-center gap-3 mb-3\">\n <a href=\"/profile/user1\" class=\"flex-shrink-0\">\n \n <div class=\"w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition\">\n <span class=\"text-brown-600 text-sm\">?</span>\n </div>\n \n </a>\n <div class=\"flex-1 min-w-0\">\n <div class=\"flex items-center gap-2\">\n \n <a href=\"/profile/user1\" class=\"font-medium text-brown-900 truncate hover:text-brown-700 hover:underline\">User One</a>\n \n <a href=\"/profile/user1\" class=\"text-brown-600 text-sm truncate hover:text-brown-700 hover:underline\">@user1</a>\n </div>\n <span class=\"text-brown-500 text-sm\">1 hour ago</span>\n </div>\n </div>\n\n \n <div class=\"mb-2 text-sm text-brown-700\">\n ☕ added a new brew\n </div>\n\n \n \n \n <div class=\"bg-white/60 backdrop-blur rounded-lg p-4 border border-brown-200\">\n \n <div class=\"flex items-start justify-between gap-3 mb-3\">\n <div class=\"flex-1 min-w-0\">\n \n <div class=\"font-bold text-brown-900 text-base\">\n Ethiopian Yirgacheffe\n </div>\n \n <div class=\"text-sm text-brown-700 mt-0.5\">\n <span class=\"font-medium\">🏭 Onyx</span>\n </div>\n \n <div class=\"text-xs text-brown-600 mt-1 flex flex-wrap gap-x-2 gap-y-0.5\">\n <span class=\"inline-flex items-center gap-0.5\">📍 Ethiopia</span>\n <span class=\"inline-flex items-center gap-0.5\">🔥 Light</span>\n <span class=\"inline-flex items-center gap-0.5\">🌱 Washed</span>\n <span class=\"inline-flex items-center gap-0.5\">⚖️ 16g</span>\n </div>\n \n </div>\n \n <span class=\"inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-900 flex-shrink-0\">\n ⭐ 9/10\n </span>\n \n </div>\n \n \n \n <div class=\"mb-2\">\n <span class=\"text-xs text-brown-600\">Brewer:</span>\n <span class=\"text-sm font-semibold text-brown-900\">\n Hario V60\n </span>\n </div>\n \n \n \n <div class=\"grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-brown-700\">\n \n <div>\n <span class=\"text-brown-600\">Grinder:</span> 1Zpresso JX-Pro (Medium-fine)\n </div>\n \n \n <div class=\"col-span-2\">\n <span class=\"text-brown-600\">Pours:</span>\n \n <div class=\"pl-2 text-brown-600\">• 50g @ 30s</div>\n \n <div class=\"pl-2 text-brown-600\">• 100g @ 45s</div>\n \n <div class=\"pl-2 text-brown-600\">• 100g @ 1m</div>\n \n </div>\n \n \n <div>\n <span class=\"text-brown-600\">Temp:</span> 93.0°C\n </div>\n \n \n <div>\n <span class=\"text-brown-600\">Time:</span> 3m\n </div>\n \n </div>\n\n \n <div class=\"mt-3 text-sm text-brown-800 italic border-t border-brown-200 pt-2\">\n \"Bright citrus notes with floral aroma\"\n </div>\n \n </div>\n \n </div>\n \n <div class=\"bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow\">\n \n <div class=\"flex items-center gap-3 mb-3\">\n <a href=\"/profile/user2\" class=\"flex-shrink-0\">\n \n \n \n <img src=\"https://cdn.bsky.app/avatar.jpg\" alt=\"\" class=\"w-10 h-10 rounded-full object-cover hover:ring-2 hover:ring-brown-600 transition\" />\n \n \n </a>\n <div class=\"flex-1 min-w-0\">\n <div class=\"flex items-center gap-2\">\n \n <a href=\"/profile/user2\" class=\"font-medium text-brown-900 truncate hover:text-brown-700 hover:underline\">User Two</a>\n \n <a href=\"/profile/user2\" class=\"text-brown-600 text-sm truncate hover:text-brown-700 hover:underline\">@user2</a>\n </div>\n <span class=\"text-brown-500 text-sm\">1.5 hours ago</span>\n </div>\n </div>\n\n \n <div class=\"mb-2 text-sm text-brown-700\">\n 🫘 added a new bean\n </div>\n\n \n \n \n <div class=\"bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200\">\n <div class=\"text-base mb-2\">\n <span class=\"font-bold text-brown-900\">\n Kenya AA\n </span>\n \n <span class=\"text-brown-700\"> from Onyx Coffee Lab</span>\n \n </div>\n <div class=\"text-sm text-brown-700 space-y-1\">\n \n <div><span class=\"text-brown-600\">Origin:</span> Kenya</div>\n \n \n <div><span class=\"text-brown-600\">Roast:</span> Medium</div>\n \n \n <div><span class=\"text-brown-600\">Process:</span> Natural</div>\n \n \n <div class=\"mt-2 text-brown-800 italic\">\"Sweet and fruity with notes of blueberry\"</div>\n \n </div>\n </div>\n \n </div>\n \n <div class=\"bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow\">\n \n <div class=\"flex items-center gap-3 mb-3\">\n <a href=\"/profile/user3\" class=\"flex-shrink-0\">\n \n <div class=\"w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition\">\n <span class=\"text-brown-600 text-sm\">?</span>\n </div>\n \n </a>\n <div class=\"flex-1 min-w-0\">\n <div class=\"flex items-center gap-2\">\n \n <a href=\"/profile/user3\" class=\"font-medium text-brown-900 truncate hover:text-brown-700 hover:underline\">User Three</a>\n \n <a href=\"/profile/user3\" class=\"text-brown-600 text-sm truncate hover:text-brown-700 hover:underline\">@user3</a>\n </div>\n <span class=\"text-brown-500 text-sm\">2 hours ago</span>\n </div>\n </div>\n\n \n <div class=\"mb-2 text-sm text-brown-700\">\n 🏪 added a new roaster\n </div>\n\n \n \n \n <div class=\"bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200\">\n <div class=\"text-base mb-2\">\n <span class=\"font-bold text-brown-900\">Heart Coffee Roasters</span>\n </div>\n <div class=\"text-sm text-brown-700 space-y-1\">\n \n <div><span class=\"text-brown-600\">Location:</span> Portland, OR</div>\n \n \n \n \n <div><span class=\"text-brown-600\">Website:</span> <a href=\"https://heartroasters.com\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-brown-800 hover:underline\">https://heartroasters.com</a></div>\n \n \n </div>\n </div>\n \n </div>\n \n <div class=\"bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow\">\n \n <div class=\"flex items-center gap-3 mb-3\">\n <a href=\"/profile/user4\" class=\"flex-shrink-0\">\n \n <div class=\"w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition\">\n <span class=\"text-brown-600 text-sm\">?</span>\n </div>\n \n </a>\n <div class=\"flex-1 min-w-0\">\n <div class=\"flex items-center gap-2\">\n \n <a href=\"/profile/user4\" class=\"text-brown-600 text-sm truncate hover:text-brown-700 hover:underline\">@user4</a>\n </div>\n <span class=\"text-brown-500 text-sm\">2.5 hours ago</span>\n </div>\n </div>\n\n \n <div class=\"mb-2 text-sm text-brown-700\">\n ⚙️ added a new grinder\n </div>\n\n \n \n \n <div class=\"bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200\">\n <div class=\"text-base mb-2\">\n <span class=\"font-bold text-brown-900\">Comandante C40</span>\n </div>\n <div class=\"text-sm text-brown-700 space-y-1\">\n \n <div><span class=\"text-brown-600\">Type:</span> Hand</div>\n \n \n <div><span class=\"text-brown-600\">Burr:</span> Conical</div>\n \n \n <div class=\"mt-2 text-brown-800 italic\">\"Excellent for pour over\"</div>\n \n </div>\n </div>\n \n </div>\n \n <div class=\"bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow\">\n \n <div class=\"flex items-center gap-3 mb-3\">\n <a href=\"/profile/user5\" class=\"flex-shrink-0\">\n \n <div class=\"w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition\">\n <span class=\"text-brown-600 text-sm\">?</span>\n </div>\n \n </a>\n <div class=\"flex-1 min-w-0\">\n <div class=\"flex items-center gap-2\">\n \n <a href=\"/profile/user5\" class=\"font-medium text-brown-900 truncate hover:text-brown-700 hover:underline\">User Five</a>\n \n <a href=\"/profile/user5\" class=\"text-brown-600 text-sm truncate hover:text-brown-700 hover:underline\">@user5</a>\n </div>\n <span class=\"text-brown-500 text-sm\">3 hours ago</span>\n </div>\n </div>\n\n \n <div class=\"mb-2 text-sm text-brown-700\">\n ☕ added a new brewer\n </div>\n\n \n \n \n <div class=\"bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200\">\n <div class=\"text-base mb-2\">\n <span class=\"font-bold text-brown-900\">Kalita Wave 185</span>\n </div>\n \n <div class=\"text-sm text-brown-800 italic\">\"Flat-bottom dripper with wave filters\"</div>\n \n </div>\n \n </div>\n \n \n \n</div>\n"
+1 -1
internal/bff/__snapshots__/profile_roaster_with_invalid_url_protocol.snap
··· 4 4 file_name: profile_template_snapshot_test.go 5 5 version: 0.1.0 6 6 --- 7 - "\n\n<div id=\"profile-stats-data\" \n data-brews=\"0\" \n data-beans=\"0\" \n data-roasters=\"1\" \n data-grinders=\"0\" \n data-brewers=\"0\"\n style=\"display: none;\"></div>\n\n\n<div x-show=\"activeTab === 'brews'\">\n \n\n<div class=\"bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center border border-brown-300\">\n \n <p class=\"text-brown-800 text-lg font-medium\">No brews yet.</p>\n \n</div>\n\n\n</div>\n\n\n<div x-show=\"activeTab === 'beans'\" x-cloak class=\"space-y-6\">\n \n \n\n \n \n <div>\n <h3 class=\"text-lg font-semibold text-brown-900 mb-3\">🏪 Favorite Roasters</h3>\n <div class=\"overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300\">\n <table class=\"min-w-full divide-y divide-brown-300\">\n <thead class=\"bg-brown-200/80\">\n <tr>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">Name</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">📍 Location</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">🌐 Website</th>\n </tr>\n </thead>\n <tbody class=\"bg-brown-50/60 divide-y divide-brown-200\">\n \n <tr class=\"hover:bg-brown-100/60 transition-colors\">\n <td class=\"px-6 py-4 text-sm font-bold text-brown-900\">FTP Roaster</td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n <span class=\"text-brown-400\">-</span>\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n \n \n \n <span class=\"text-brown-400\">-</span>\n \n \n </td>\n </tr>\n \n </tbody>\n </table>\n </div>\n \n </div>\n \n\n \n</div>\n\n\n<div x-show=\"activeTab === 'gear'\" x-cloak class=\"space-y-6\">\n \n \n\n \n \n\n \n <div class=\"bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center text-brown-800 border border-brown-300\">\n <p class=\"font-medium\">No gear added yet.</p>\n </div>\n \n</div>\n" 7 + "\n\n<div id=\"profile-stats-data\" \n data-brews=\"0\" \n data-beans=\"0\" \n data-roasters=\"1\" \n data-grinders=\"0\" \n data-brewers=\"0\"\n style=\"display: none;\"></div>\n\n\n<div x-show=\"activeTab === 'brews'\">\n \n\n<div class=\"bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center border border-brown-300\">\n \n <p class=\"text-brown-800 text-lg font-medium\">No brews yet.</p>\n \n</div>\n\n\n</div>\n\n\n<div x-show=\"activeTab === 'beans'\" x-cloak class=\"space-y-6\">\n \n \n\n \n \n <div>\n <h3 class=\"text-lg font-semibold text-brown-900 mb-3\">🏭 Favorite Roasters</h3>\n <div class=\"overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300\">\n <table class=\"min-w-full divide-y divide-brown-300\">\n <thead class=\"bg-brown-200/80\">\n <tr>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">Name</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">📍 Location</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">🌐 Website</th>\n </tr>\n </thead>\n <tbody class=\"bg-brown-50/60 divide-y divide-brown-200\">\n \n <tr class=\"hover:bg-brown-100/60 transition-colors\">\n <td class=\"px-6 py-4 text-sm font-bold text-brown-900\">FTP Roaster</td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n <span class=\"text-brown-400\">-</span>\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n \n \n \n <span class=\"text-brown-400\">-</span>\n \n \n </td>\n </tr>\n \n </tbody>\n </table>\n </div>\n \n </div>\n \n\n \n</div>\n\n\n<div x-show=\"activeTab === 'gear'\" x-cloak class=\"space-y-6\">\n \n \n\n \n \n\n \n <div class=\"bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center text-brown-800 border border-brown-300\">\n <p class=\"font-medium\">No gear added yet.</p>\n </div>\n \n</div>\n"
+1 -1
internal/bff/__snapshots__/profile_roaster_with_unsafe_website_url.snap
··· 4 4 file_name: profile_template_snapshot_test.go 5 5 version: 0.1.0 6 6 --- 7 - "\n\n<div id=\"profile-stats-data\" \n data-brews=\"0\" \n data-beans=\"0\" \n data-roasters=\"1\" \n data-grinders=\"0\" \n data-brewers=\"0\"\n style=\"display: none;\"></div>\n\n\n<div x-show=\"activeTab === 'brews'\">\n \n\n<div class=\"bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center border border-brown-300\">\n \n <p class=\"text-brown-800 text-lg font-medium\">No brews yet.</p>\n \n</div>\n\n\n</div>\n\n\n<div x-show=\"activeTab === 'beans'\" x-cloak class=\"space-y-6\">\n \n \n\n \n \n <div>\n <h3 class=\"text-lg font-semibold text-brown-900 mb-3\">🏪 Favorite Roasters</h3>\n <div class=\"overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300\">\n <table class=\"min-w-full divide-y divide-brown-300\">\n <thead class=\"bg-brown-200/80\">\n <tr>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">Name</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">📍 Location</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">🌐 Website</th>\n </tr>\n </thead>\n <tbody class=\"bg-brown-50/60 divide-y divide-brown-200\">\n \n <tr class=\"hover:bg-brown-100/60 transition-colors\">\n <td class=\"px-6 py-4 text-sm font-bold text-brown-900\">Sketchy Roaster</td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n Unknown\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n \n \n \n <span class=\"text-brown-400\">-</span>\n \n \n </td>\n </tr>\n \n </tbody>\n </table>\n </div>\n \n </div>\n \n\n \n</div>\n\n\n<div x-show=\"activeTab === 'gear'\" x-cloak class=\"space-y-6\">\n \n \n\n \n \n\n \n <div class=\"bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center text-brown-800 border border-brown-300\">\n <p class=\"font-medium\">No gear added yet.</p>\n </div>\n \n</div>\n" 7 + "\n\n<div id=\"profile-stats-data\" \n data-brews=\"0\" \n data-beans=\"0\" \n data-roasters=\"1\" \n data-grinders=\"0\" \n data-brewers=\"0\"\n style=\"display: none;\"></div>\n\n\n<div x-show=\"activeTab === 'brews'\">\n \n\n<div class=\"bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center border border-brown-300\">\n \n <p class=\"text-brown-800 text-lg font-medium\">No brews yet.</p>\n \n</div>\n\n\n</div>\n\n\n<div x-show=\"activeTab === 'beans'\" x-cloak class=\"space-y-6\">\n \n \n\n \n \n <div>\n <h3 class=\"text-lg font-semibold text-brown-900 mb-3\">🏭 Favorite Roasters</h3>\n <div class=\"overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300\">\n <table class=\"min-w-full divide-y divide-brown-300\">\n <thead class=\"bg-brown-200/80\">\n <tr>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">Name</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">📍 Location</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">🌐 Website</th>\n </tr>\n </thead>\n <tbody class=\"bg-brown-50/60 divide-y divide-brown-200\">\n \n <tr class=\"hover:bg-brown-100/60 transition-colors\">\n <td class=\"px-6 py-4 text-sm font-bold text-brown-900\">Sketchy Roaster</td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n Unknown\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n \n \n \n <span class=\"text-brown-400\">-</span>\n \n \n </td>\n </tr>\n \n </tbody>\n </table>\n </div>\n \n </div>\n \n\n \n</div>\n\n\n<div x-show=\"activeTab === 'gear'\" x-cloak class=\"space-y-6\">\n \n \n\n \n \n\n \n <div class=\"bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center text-brown-800 border border-brown-300\">\n <p class=\"font-medium\">No gear added yet.</p>\n </div>\n \n</div>\n"
+1 -1
internal/bff/__snapshots__/profile_with_gear_collection.snap
··· 4 4 file_name: profile_template_snapshot_test.go 5 5 version: 0.1.0 6 6 --- 7 - "\n\n<div id=\"profile-stats-data\" \n data-brews=\"0\" \n data-beans=\"0\" \n data-roasters=\"1\" \n data-grinders=\"2\" \n data-brewers=\"1\"\n style=\"display: none;\"></div>\n\n\n<div x-show=\"activeTab === 'brews'\">\n \n\n<div class=\"bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center border border-brown-300\">\n \n <p class=\"text-brown-800 text-lg mb-4 font-medium\">No brews yet! Start tracking your coffee journey.</p>\n <a href=\"/brews/new\"\n class=\"inline-block bg-gradient-to-r from-brown-700 to-brown-800 text-white py-3 px-6 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all shadow-lg hover:shadow-xl font-medium\">\n Add Your First Brew\n </a>\n \n</div>\n\n\n</div>\n\n\n<div x-show=\"activeTab === 'beans'\" x-cloak class=\"space-y-6\">\n \n \n\n \n \n <div>\n <h3 class=\"text-lg font-semibold text-brown-900 mb-3\">🏪 Favorite Roasters</h3>\n <div class=\"overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300\">\n <table class=\"min-w-full divide-y divide-brown-300\">\n <thead class=\"bg-brown-200/80\">\n <tr>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">Name</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">📍 Location</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">🌐 Website</th>\n </tr>\n </thead>\n <tbody class=\"bg-brown-50/60 divide-y divide-brown-200\">\n \n <tr class=\"hover:bg-brown-100/60 transition-colors\">\n <td class=\"px-6 py-4 text-sm font-bold text-brown-900\">Heart Coffee</td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n Portland, OR\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n \n \n \n <a href=\"https://heartroasters.com\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-brown-700 hover:underline font-medium\">Visit Site</a>\n \n \n </td>\n </tr>\n \n </tbody>\n </table>\n </div>\n \n <div class=\"mt-3 text-center\">\n <button @click=\"editRoaster('', '', '', '')\" class=\"inline-flex items-center gap-2 bg-brown-600 text-white px-4 py-2 rounded-lg hover:bg-brown-700 transition-all shadow-md hover:shadow-lg text-sm font-medium\">\n <span>+</span>\n <span>Add New Roaster</span>\n </button>\n </div>\n \n </div>\n \n\n \n</div>\n\n\n<div x-show=\"activeTab === 'gear'\" x-cloak class=\"space-y-6\">\n \n \n <div>\n <h3 class=\"text-lg font-semibold text-brown-900 mb-3\">⚙️ Grinders</h3>\n <div class=\"overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300\">\n <table class=\"min-w-full divide-y divide-brown-300\">\n <thead class=\"bg-brown-200/80\">\n <tr>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">Name</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">🔧 Type</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">💎 Burrs</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">📝 Notes</th>\n </tr>\n </thead>\n <tbody class=\"bg-brown-50/60 divide-y divide-brown-200\">\n \n <tr class=\"hover:bg-brown-100/60 transition-colors\">\n <td class=\"px-6 py-4 text-sm font-bold text-brown-900\">Comandante C40</td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n Hand\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n Conical\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-700 italic max-w-xs\">\n Perfect for pour over\n </td>\n </tr>\n \n <tr class=\"hover:bg-brown-100/60 transition-colors\">\n <td class=\"px-6 py-4 text-sm font-bold text-brown-900\">Niche Zero</td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n Electric\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n Conical\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-700 italic max-w-xs\">\n <span class=\"text-brown-400 not-italic\">-</span>\n </td>\n </tr>\n \n </tbody>\n </table>\n </div>\n \n <div class=\"mt-3 text-center\">\n <button @click=\"editGrinder('', '', '', '', '')\" class=\"inline-flex items-center gap-2 bg-brown-600 text-white px-4 py-2 rounded-lg hover:bg-brown-700 transition-all shadow-md hover:shadow-lg text-sm font-medium\">\n <span>+</span>\n <span>Add New Grinder</span>\n </button>\n </div>\n \n </div>\n \n\n \n \n <div>\n <h3 class=\"text-lg font-semibold text-brown-900 mb-3\">☕ Brewers</h3>\n <div class=\"overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300\">\n <table class=\"min-w-full divide-y divide-brown-300\">\n <thead class=\"bg-brown-200/80\">\n <tr>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">Name</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">🔧 Type</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">📝 Description</th>\n </tr>\n </thead>\n <tbody class=\"bg-brown-50/60 divide-y divide-brown-200\">\n \n <tr class=\"hover:bg-brown-100/60 transition-colors\">\n <td class=\"px-6 py-4 text-sm font-bold text-brown-900\">Hario V60</td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n Pour Over\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-700 italic max-w-xs\">\n Classic pour over cone\n </td>\n </tr>\n \n </tbody>\n </table>\n </div>\n \n <div class=\"mt-3 text-center\">\n <button @click=\"editBrewer('', '', '', '')\" class=\"inline-flex items-center gap-2 bg-brown-600 text-white px-4 py-2 rounded-lg hover:bg-brown-700 transition-all shadow-md hover:shadow-lg text-sm font-medium\">\n <span>+</span>\n <span>Add New Brewer</span>\n </button>\n </div>\n \n </div>\n \n\n \n</div>\n" 7 + "\n\n<div id=\"profile-stats-data\" \n data-brews=\"0\" \n data-beans=\"0\" \n data-roasters=\"1\" \n data-grinders=\"2\" \n data-brewers=\"1\"\n style=\"display: none;\"></div>\n\n\n<div x-show=\"activeTab === 'brews'\">\n \n\n<div class=\"bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center border border-brown-300\">\n \n <p class=\"text-brown-800 text-lg mb-4 font-medium\">No brews yet! Start tracking your coffee journey.</p>\n <a href=\"/brews/new\"\n class=\"inline-block bg-gradient-to-r from-brown-700 to-brown-800 text-white py-3 px-6 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all shadow-lg hover:shadow-xl font-medium\">\n Add Your First Brew\n </a>\n \n</div>\n\n\n</div>\n\n\n<div x-show=\"activeTab === 'beans'\" x-cloak class=\"space-y-6\">\n \n \n\n \n \n <div>\n <h3 class=\"text-lg font-semibold text-brown-900 mb-3\">🏭 Favorite Roasters</h3>\n <div class=\"overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300\">\n <table class=\"min-w-full divide-y divide-brown-300\">\n <thead class=\"bg-brown-200/80\">\n <tr>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">Name</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">📍 Location</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">🌐 Website</th>\n </tr>\n </thead>\n <tbody class=\"bg-brown-50/60 divide-y divide-brown-200\">\n \n <tr class=\"hover:bg-brown-100/60 transition-colors\">\n <td class=\"px-6 py-4 text-sm font-bold text-brown-900\">Heart Coffee</td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n Portland, OR\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n \n \n \n <a href=\"https://heartroasters.com\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-brown-700 hover:underline font-medium\">Visit Site</a>\n \n \n </td>\n </tr>\n \n </tbody>\n </table>\n </div>\n \n <div class=\"mt-3 text-center\">\n <button @click=\"editRoaster('', '', '', '')\" class=\"inline-flex items-center gap-2 bg-brown-600 text-white px-4 py-2 rounded-lg hover:bg-brown-700 transition-all shadow-md hover:shadow-lg text-sm font-medium\">\n <span>+</span>\n <span>Add New Roaster</span>\n </button>\n </div>\n \n </div>\n \n\n \n</div>\n\n\n<div x-show=\"activeTab === 'gear'\" x-cloak class=\"space-y-6\">\n \n \n <div>\n <h3 class=\"text-lg font-semibold text-brown-900 mb-3\">⚙️ Grinders</h3>\n <div class=\"overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300\">\n <table class=\"min-w-full divide-y divide-brown-300\">\n <thead class=\"bg-brown-200/80\">\n <tr>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">Name</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">🔧 Type</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">💎 Burrs</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">📝 Notes</th>\n </tr>\n </thead>\n <tbody class=\"bg-brown-50/60 divide-y divide-brown-200\">\n \n <tr class=\"hover:bg-brown-100/60 transition-colors\">\n <td class=\"px-6 py-4 text-sm font-bold text-brown-900\">Comandante C40</td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n Hand\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n Conical\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-700 italic max-w-xs\">\n Perfect for pour over\n </td>\n </tr>\n \n <tr class=\"hover:bg-brown-100/60 transition-colors\">\n <td class=\"px-6 py-4 text-sm font-bold text-brown-900\">Niche Zero</td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n Electric\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n Conical\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-700 italic max-w-xs\">\n <span class=\"text-brown-400 not-italic\">-</span>\n </td>\n </tr>\n \n </tbody>\n </table>\n </div>\n \n <div class=\"mt-3 text-center\">\n <button @click=\"editGrinder('', '', '', '', '')\" class=\"inline-flex items-center gap-2 bg-brown-600 text-white px-4 py-2 rounded-lg hover:bg-brown-700 transition-all shadow-md hover:shadow-lg text-sm font-medium\">\n <span>+</span>\n <span>Add New Grinder</span>\n </button>\n </div>\n \n </div>\n \n\n \n \n <div>\n <h3 class=\"text-lg font-semibold text-brown-900 mb-3\">☕ Brewers</h3>\n <div class=\"overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300\">\n <table class=\"min-w-full divide-y divide-brown-300\">\n <thead class=\"bg-brown-200/80\">\n <tr>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">Name</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">🔧 Type</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">📝 Description</th>\n </tr>\n </thead>\n <tbody class=\"bg-brown-50/60 divide-y divide-brown-200\">\n \n <tr class=\"hover:bg-brown-100/60 transition-colors\">\n <td class=\"px-6 py-4 text-sm font-bold text-brown-900\">Hario V60</td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n Pour Over\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-700 italic max-w-xs\">\n Classic pour over cone\n </td>\n </tr>\n \n </tbody>\n </table>\n </div>\n \n <div class=\"mt-3 text-center\">\n <button @click=\"editBrewer('', '', '', '')\" class=\"inline-flex items-center gap-2 bg-brown-600 text-white px-4 py-2 rounded-lg hover:bg-brown-700 transition-all shadow-md hover:shadow-lg text-sm font-medium\">\n <span>+</span>\n <span>Add New Brewer</span>\n </button>\n </div>\n \n </div>\n \n\n \n</div>\n"
+1 -1
internal/bff/__snapshots__/profile_with_unicode_content.snap
··· 4 4 file_name: profile_template_snapshot_test.go 5 5 version: 0.1.0 6 6 --- 7 - "\n\n<div id=\"profile-stats-data\" \n data-brews=\"0\" \n data-beans=\"2\" \n data-roasters=\"1\" \n data-grinders=\"0\" \n data-brewers=\"0\"\n style=\"display: none;\"></div>\n\n\n<div x-show=\"activeTab === 'brews'\">\n \n\n<div class=\"bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center border border-brown-300\">\n \n <p class=\"text-brown-800 text-lg font-medium\">No brews yet.</p>\n \n</div>\n\n\n</div>\n\n\n<div x-show=\"activeTab === 'beans'\" x-cloak class=\"space-y-6\">\n \n \n <div>\n <h3 class=\"text-lg font-semibold text-brown-900 mb-3\">☕ Coffee Beans</h3>\n <div class=\"overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300\">\n <table class=\"min-w-full divide-y divide-brown-300\">\n <thead class=\"bg-brown-200/80\">\n <tr>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">Name</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">☕ Roaster</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">📍 Origin</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">🔥 Roast</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">🌱 Process</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">📝 Description</th>\n </tr>\n </thead>\n <tbody class=\"bg-brown-50/60 divide-y divide-brown-200\">\n \n <tr class=\"hover:bg-brown-100/60 transition-colors\">\n <td class=\"px-6 py-4 text-sm font-bold text-brown-900\">\n エチオピア イルガチェフェ\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n \n <span class=\"text-brown-400\">-</span>\n \n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n 日本\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n <span class=\"text-brown-400\">-</span>\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n <span class=\"text-brown-400\">-</span>\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-700 italic max-w-xs\">\n 明るく花のような香り\n </td>\n </tr>\n \n <tr class=\"hover:bg-brown-100/60 transition-colors\">\n <td class=\"px-6 py-4 text-sm font-bold text-brown-900\">\n Café de Colombia\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n \n <span class=\"text-brown-400\">-</span>\n \n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n Bogotá\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n <span class=\"text-brown-400\">-</span>\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n <span class=\"text-brown-400\">-</span>\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-700 italic max-w-xs\">\n Suave y aromático\n </td>\n </tr>\n \n </tbody>\n </table>\n </div>\n \n </div>\n \n\n \n \n <div>\n <h3 class=\"text-lg font-semibold text-brown-900 mb-3\">🏪 Favorite Roasters</h3>\n <div class=\"overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300\">\n <table class=\"min-w-full divide-y divide-brown-300\">\n <thead class=\"bg-brown-200/80\">\n <tr>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">Name</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">📍 Location</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">🌐 Website</th>\n </tr>\n </thead>\n <tbody class=\"bg-brown-50/60 divide-y divide-brown-200\">\n \n <tr class=\"hover:bg-brown-100/60 transition-colors\">\n <td class=\"px-6 py-4 text-sm font-bold text-brown-900\">Кофейня Москва</td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n Москва, Россия\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n \n <span class=\"text-brown-400\">-</span>\n \n </td>\n </tr>\n \n </tbody>\n </table>\n </div>\n \n </div>\n \n\n \n</div>\n\n\n<div x-show=\"activeTab === 'gear'\" x-cloak class=\"space-y-6\">\n \n \n\n \n \n\n \n <div class=\"bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center text-brown-800 border border-brown-300\">\n <p class=\"font-medium\">No gear added yet.</p>\n </div>\n \n</div>\n" 7 + "\n\n<div id=\"profile-stats-data\" \n data-brews=\"0\" \n data-beans=\"2\" \n data-roasters=\"1\" \n data-grinders=\"0\" \n data-brewers=\"0\"\n style=\"display: none;\"></div>\n\n\n<div x-show=\"activeTab === 'brews'\">\n \n\n<div class=\"bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center border border-brown-300\">\n \n <p class=\"text-brown-800 text-lg font-medium\">No brews yet.</p>\n \n</div>\n\n\n</div>\n\n\n<div x-show=\"activeTab === 'beans'\" x-cloak class=\"space-y-6\">\n \n \n <div>\n <h3 class=\"text-lg font-semibold text-brown-900 mb-3\">☕ Coffee Beans</h3>\n <div class=\"overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300\">\n <table class=\"min-w-full divide-y divide-brown-300\">\n <thead class=\"bg-brown-200/80\">\n <tr>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">Name</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">☕ Roaster</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">📍 Origin</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">🔥 Roast</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">🌱 Process</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">📝 Description</th>\n </tr>\n </thead>\n <tbody class=\"bg-brown-50/60 divide-y divide-brown-200\">\n \n <tr class=\"hover:bg-brown-100/60 transition-colors\">\n <td class=\"px-6 py-4 text-sm font-bold text-brown-900\">\n エチオピア イルガチェフェ\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n \n <span class=\"text-brown-400\">-</span>\n \n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n 日本\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n <span class=\"text-brown-400\">-</span>\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n <span class=\"text-brown-400\">-</span>\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-700 italic max-w-xs\">\n 明るく花のような香り\n </td>\n </tr>\n \n <tr class=\"hover:bg-brown-100/60 transition-colors\">\n <td class=\"px-6 py-4 text-sm font-bold text-brown-900\">\n Café de Colombia\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n \n <span class=\"text-brown-400\">-</span>\n \n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n Bogotá\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n <span class=\"text-brown-400\">-</span>\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n <span class=\"text-brown-400\">-</span>\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-700 italic max-w-xs\">\n Suave y aromático\n </td>\n </tr>\n \n </tbody>\n </table>\n </div>\n \n </div>\n \n\n \n \n <div>\n <h3 class=\"text-lg font-semibold text-brown-900 mb-3\">🏭 Favorite Roasters</h3>\n <div class=\"overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300\">\n <table class=\"min-w-full divide-y divide-brown-300\">\n <thead class=\"bg-brown-200/80\">\n <tr>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">Name</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">📍 Location</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">🌐 Website</th>\n </tr>\n </thead>\n <tbody class=\"bg-brown-50/60 divide-y divide-brown-200\">\n \n <tr class=\"hover:bg-brown-100/60 transition-colors\">\n <td class=\"px-6 py-4 text-sm font-bold text-brown-900\">Кофейня Москва</td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n Москва, Россия\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n \n <span class=\"text-brown-400\">-</span>\n \n </td>\n </tr>\n \n </tbody>\n </table>\n </div>\n \n </div>\n \n\n \n</div>\n\n\n<div x-show=\"activeTab === 'gear'\" x-cloak class=\"space-y-6\">\n \n \n\n \n \n\n \n <div class=\"bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center text-brown-800 border border-brown-300\">\n <p class=\"font-medium\">No gear added yet.</p>\n </div>\n \n</div>\n"
+1 -1
justfile
··· 1 1 run: 2 - @LOG_LEVEL=debug LOG_FORMAT=console go run cmd/server/main.go -firehose 2 + @LOG_LEVEL=debug LOG_FORMAT=console go run cmd/server/main.go -firehose -known-dids known-dids.txt 3 3 4 4 run-production: 5 5 @LOG_FORMAT=json SECURE_COOKIES=true go run cmd/server/main.go -firehose
+18
known-dids.txt.example
··· 1 + # Known DIDs for Development Backfill 2 + # 3 + # This file contains DIDs that should be backfilled on startup when using 4 + # the --known-dids flag. This is useful for development and testing to 5 + # populate the feed with known coffee enthusiasts. 6 + # 7 + # Format: One DID per line 8 + # Lines starting with # are comments 9 + # Empty lines are ignored 10 + # 11 + # Example DIDs (replace with real DIDs): 12 + # did:plc:example1234567890abcdef 13 + # did:plc:another1234567890abcdef 14 + # 15 + # To use this file: 16 + # 1. Copy this file to known-dids.txt 17 + # 2. Add real DIDs (one per line) 18 + # 3. Run: ./arabica --firehose --known-dids known-dids.txt
+3 -3
templates/profile.tmpl
··· 6 6 <!-- Load profile stats updater --> 7 7 <script src="/static/js/profile-stats.js"></script> 8 8 {{if .IsOwnProfile}} 9 - <div class="max-w-4xl mx-auto" x-data="managePage()"> 9 + <div class="max-w-4xl mx-auto" x-data="managePage()" @htmx:after-swap.window="$nextTick(() => Alpine.initTree($el))"> 10 10 {{else}} 11 - <div class="max-w-4xl mx-auto" x-data="{ activeTab: 'brews' }"> 11 + <div class="max-w-4xl mx-auto" x-data="{ activeTab: 'brews' }" @htmx:after-swap.window="$nextTick(() => Alpine.initTree($el))"> 12 12 {{end}} 13 13 <!-- Profile Header --> 14 14 <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-6 mb-6 border border-brown-300"> ··· 87 87 </div> 88 88 89 89 <!-- Tab content loaded via HTMX --> 90 - <div id="profile-content" hx-get="/api/profile/{{.Profile.Handle}}" hx-trigger="load" hx-swap="innerHTML" hx-on::after-swap="Alpine.initTree($el)"> 90 + <div id="profile-content" hx-get="/api/profile/{{.Profile.Handle}}" hx-trigger="load" hx-swap="innerHTML"> 91 91 <!-- Loading skeleton --> 92 92 <div class="animate-pulse"> 93 93 <!-- Brews Tab Skeleton -->