diff --git a/BACKLOG.md b/BACKLOG.md index 8ae015e..3011570 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -40,3 +40,13 @@ Each should be addressed one at a time, and the item should be removed after imp - Header should probably always be attached to the top of the screen? - Feed item "view details" button should go away, the "new brew" in "addded a new brew" should take to view page instead (underline this text) + +- Manage brews page truncates notes, but variables wrap to considerably more lines, probably don't need to truncate notes. + +- Loading on htmx could probably be snappier by using a loading bar, and waiting until everything is loaded + - Alternative could be using transitionary animations between skeleton and full loads + - Do we even need skeleton loading with SSR? (I think yes here because of PDS data fetches -- maybe not if we kept a copy of the data) + +- I think we should be using htmx more than alpine, we are leaning too much on alpine and not enough on htmx + - I think for transitions we should be able to swap to htmx from alpine? + - I may be completely off base on this though diff --git a/TEMPL_POC.md b/TEMPL_POC.md new file mode 100644 index 0000000..436ecb4 --- /dev/null +++ b/TEMPL_POC.md @@ -0,0 +1,202 @@ +# Templ POC - Phase 1 Complete ✅ + +This document describes the Proof of Concept (POC) implementation of Templ for the Arabica coffee tracking application. + +## What Was Implemented + +### Phase 1: Setup & Learning (COMPLETED) + +We successfully completed the first phase of the Templ hybrid approach migration: + +1. **Development Environment Setup** + - Added `templ` to the Nix flake development shell (`flake.nix`) + - Added `github.com/a-h/templ` Go module dependency + - Templ CLI version: 0.3.960 (from Nix) + - Templ Go module: 0.3.977 + +2. **Component Directory Structure** + - Created `internal/components/` directory + - Established pattern for Templ component organization + +3. **Converted Components** (4 total) + - **Layout** (`layout.templ`) - Base HTML layout with head, body structure + - **Header** (`header.templ`) - Navigation bar with Alpine.js dropdown + - **Footer** (`footer.templ`) - Site footer with links + - **About Page** (`about.templ`) - Full About page content + +4. **Handler Integration** + - Updated `internal/handlers/handlers.go` to import components + - Modified `HandleAbout` to use `components.About()` instead of `bff.RenderTemplate()` + - Successfully maintains Alpine.js functionality (user dropdown) + +5. **Testing** + - Created `about_test.go` with component tests + - All tests passing ✅ + - Verified authenticated/unauthenticated states render correctly + +## Key Learnings + +### Templ Syntax Patterns + +1. **Conditional Rendering** + + ```templ + if isAuthenticated { +
Authenticated content
+ } + ``` + +2. **String Interpolation** + + ```templ + { userProfile.DisplayName } // Simple variable + { "@" + userProfile.Handle } // Concatenation (for literal @ symbol) + ``` + +3. **Component Composition** + + ```templ + templ About(data *LayoutData) { + @Layout(data, AboutContent(data.IsAuthenticated)) + } + ``` + +4. **Alpine.js Compatibility** + - Alpine.js directives (`x-data`, `x-show`, `@click`, etc.) work perfectly in Templ + - No changes needed to Alpine.js usage patterns + +### Build Process + +```bash +# Generate Go code from .templ files +nix develop -c templ generate + +# Build the application +nix develop -c go build ./... + +# Run tests +nix develop -c go test ./internal/components -v +``` + +## File Structure + +``` +internal/components/ +├── about.templ # About page component +├── about_templ.go # Generated Go code (auto-generated) +├── about_test.go # Component tests +├── footer.templ # Footer component +├── footer_templ.go # Generated Go code +├── header.templ # Header/nav component +├── header_templ.go # Generated Go code +├── layout.templ # Base layout component +└── layout_templ.go # Generated Go code +``` + +## What Works + +✅ **Type-safe templating** - Compile-time checks for data structures +✅ **Component composition** - Layout wraps content components +✅ **Alpine.js integration** - No conflicts, works seamlessly +✅ **Conditional rendering** - Authentication state handled correctly +✅ **IDE support** - Full Go autocomplete and type checking +✅ **Testing** - Easy to test components in isolation +✅ **Build integration** - Fits into existing Nix development workflow + +## What's Next (Future Phases) + +### Phase 2: Component Library + +- Create reusable UI components (buttons, cards, forms) +- Convert common partials to components +- Establish component naming conventions + +### Phase 3: Page Migration + +- Convert remaining pages (home, brews, manage, profile) +- Maintain parity with existing templates +- Ensure all HTMX interactions still work + +### Phase 4: JavaScript Simplification + +- Remove entity-manager.js (logic moves to server) +- Remove dropdown-manager.js (Templ components handle this) +- Keep Alpine.js for client-side state (modals, dynamic forms) + +### Phase 5: Cleanup & Documentation + +- Remove old .tmpl files +- Update CLAUDE.md with Templ patterns +- Add component documentation + +## Migration Considerations + +### What to Keep + +- **Alpine.js** - Essential for client-side state management + - Modal state (open/close without server round-trip) + - Dynamic form arrays (add/remove pours) + - Rating slider live feedback + - Form draft persistence to LocalStorage + +- **HTMX** - Server communication for partial updates + - Form submissions + - Content loading + - Target swapping + +- **ArabicaCache** - Client-side caching with TTL + +### What Can Be Simplified + +- **Template rendering logic** - Simpler with Templ components +- **Entity management** - Can move some logic server-side +- **Dropdown population** - Can be handled by Templ components + +## Performance Notes + +- Templ generates Go code at compile time (no runtime parsing) +- Similar performance to `html/template` but with type safety +- No impact on client-side JavaScript bundle size +- Generated code is efficient and readable + +## Recommendations + +1. **Continue with hybrid approach** - Templ + Alpine.js + HTMX +2. **Incremental migration** - One page at a time +3. **Maintain Alpine.js** - Don't try to replace client-side state management +4. **Focus on DX improvements** - Type safety and component reuse are the wins + +## Example Usage + +```go +// Handler code (internal/handlers/handlers.go) +func (h *Handler) HandleAbout(w http.ResponseWriter, r *http.Request) { + data := &components.LayoutData{ + Title: "About", + IsAuthenticated: isAuthenticated, + UserDID: didStr, + UserProfile: userProfile, + } + + // Render Templ component + if err := components.About(data).Render(r.Context(), w); err != nil { + http.Error(w, "Failed to render page", http.StatusInternalServerError) + } +} +``` + +## Conclusion + +**POC Status: ✅ SUCCESS** + +The Templ POC demonstrates that: + +- Templ integrates smoothly with existing Go code +- Alpine.js functionality is preserved +- Type safety improves developer experience +- Testing is straightforward +- Build process is manageable with Nix + +**Recommendation: Proceed with incremental migration using the hybrid approach.** + +The evaluation's conclusion holds true: Templ is an excellent `html/template` replacement that provides better DX without compromising the user experience or requiring JavaScript elimination. diff --git a/Caddyfile b/deploy/Caddyfile similarity index 100% rename from Caddyfile rename to deploy/Caddyfile diff --git a/Dockerfile b/deploy/Dockerfile similarity index 100% rename from Dockerfile rename to deploy/Dockerfile diff --git a/compose.yml b/deploy/compose.yml similarity index 100% rename from compose.yml rename to deploy/compose.yml diff --git a/flake.nix b/flake.nix index 25a1e29..fbad747 100644 --- a/flake.nix +++ b/flake.nix @@ -8,7 +8,7 @@ (system: function nixpkgs.legacyPackages.${system} system); in { devShells = forAllSystems (pkgs: system: { - default = pkgs.mkShell { packages = with pkgs; [ go tailwindcss ]; }; + default = pkgs.mkShell { packages = with pkgs; [ go tailwindcss templ ]; }; }); packages = forAllSystems (pkgs: system: rec { diff --git a/go.mod b/go.mod index 2f61f48..9ec453f 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,14 @@ module arabica go 1.25.4 require ( + github.com/a-h/templ v0.3.977 github.com/bluesky-social/indigo v0.0.0-20260106221649-6fcd9317e725 + github.com/gorilla/websocket v1.5.3 + github.com/klauspost/compress v1.18.3 + github.com/ptdewey/shutter v0.1.4 github.com/rs/zerolog v1.34.0 github.com/stretchr/testify v1.10.0 + github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4 go.etcd.io/bbolt v1.3.8 golang.org/x/sync v0.19.0 ) @@ -16,11 +21,8 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect github.com/golang-jwt/jwt/v5 v5.2.2 // indirect - github.com/google/go-cmp v0.6.0 // indirect github.com/google/go-querystring v1.1.0 // indirect - github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect - github.com/klauspost/compress v1.18.3 // indirect github.com/kortschak/utter v1.7.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -31,12 +33,10 @@ require ( github.com/prometheus/client_model v0.5.0 // indirect github.com/prometheus/common v0.45.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect - github.com/ptdewey/shutter v0.1.4 // indirect - github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4 // indirect gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect - golang.org/x/crypto v0.21.0 // indirect - golang.org/x/net v0.23.0 // indirect + golang.org/x/crypto v0.40.0 // indirect + golang.org/x/net v0.42.0 // indirect golang.org/x/sys v0.36.0 // indirect golang.org/x/time v0.3.0 // indirect google.golang.org/protobuf v1.33.0 // indirect diff --git a/go.sum b/go.sum index 5e7406e..8dea89e 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg= +github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bluesky-social/indigo v0.0.0-20260106221649-6fcd9317e725 h1:gfrLAhE6PHun4MDypO/5hpnaHPd9Dbe9+JxZL0gC4ic= @@ -87,10 +89,10 @@ gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAF gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= go.etcd.io/bbolt v1.3.8 h1:xs88BrvEv273UsB79e0hcVrlUWmS0a8upikMFhSyAtA= go.etcd.io/bbolt v1.3.8/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw= -golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= -golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= -golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= -golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/bff/__snapshots__/bean_with_roaster.snap b/internal/bff/__snapshots__/bean_with_roaster.snap index bea28bd..b59d874 100644 --- a/internal/bff/__snapshots__/bean_with_roaster.snap +++ b/internal/bff/__snapshots__/bean_with_roaster.snap @@ -5,10 +5,10 @@ file_name: feed_template_snapshot_test.go version: 0.1.0 ---
-
+
-
+
? @@ -16,10 +16,10 @@ version: 0.1.0
@@ -31,7 +31,7 @@ version: 0.1.0
🫘 added a new bean
-
+
Kenya AA @@ -42,19 +42,19 @@ version: 0.1.0
- + Origin: Kenya
- + Roast: Medium
- + Process: Natural diff --git a/internal/bff/__snapshots__/bean_without_roaster.snap b/internal/bff/__snapshots__/bean_without_roaster.snap index 59c8f91..0265b1e 100644 --- a/internal/bff/__snapshots__/bean_without_roaster.snap +++ b/internal/bff/__snapshots__/bean_without_roaster.snap @@ -5,10 +5,10 @@ file_name: feed_template_snapshot_test.go version: 0.1.0 ---
-
+
-
+
? @@ -16,10 +16,10 @@ version: 0.1.0
@@ -31,7 +31,7 @@ version: 0.1.0
🫘 added a new bean
-
+
Colombian Supremo @@ -39,19 +39,19 @@ version: 0.1.0
- + Origin: Colombia
- + Roast: Medium
- + Process: Natural diff --git a/internal/bff/__snapshots__/brew_with_minimal_data.snap b/internal/bff/__snapshots__/brew_with_minimal_data.snap index b5f2a4b..340247c 100644 --- a/internal/bff/__snapshots__/brew_with_minimal_data.snap +++ b/internal/bff/__snapshots__/brew_with_minimal_data.snap @@ -5,10 +5,10 @@ file_name: feed_template_snapshot_test.go version: 0.1.0 ---
-
+
-
+
? @@ -16,7 +16,7 @@ version: 0.1.0
@@ -28,7 +28,7 @@ version: 0.1.0
☕ added a new brew
-
+
@@ -38,6 +38,12 @@ version: 0.1.0
+
\ No newline at end of file diff --git a/internal/bff/__snapshots__/brew_with_unicode_bean_name.snap b/internal/bff/__snapshots__/brew_with_unicode_bean_name.snap index d16f0ce..3aa8a20 100644 --- a/internal/bff/__snapshots__/brew_with_unicode_bean_name.snap +++ b/internal/bff/__snapshots__/brew_with_unicode_bean_name.snap @@ -5,10 +5,10 @@ file_name: feed_template_snapshot_test.go version: 0.1.0 ---
-
+
-
+
? @@ -16,10 +16,10 @@ version: 0.1.0
@@ -31,7 +31,7 @@ version: 0.1.0
☕ added a new brew
-
+
@@ -43,11 +43,17 @@ version: 0.1.0
- + ⭐ 8/10
+
\ No newline at end of file diff --git a/internal/bff/__snapshots__/brewer_item.snap b/internal/bff/__snapshots__/brewer_item.snap index 2418431..1c7bc3e 100644 --- a/internal/bff/__snapshots__/brewer_item.snap +++ b/internal/bff/__snapshots__/brewer_item.snap @@ -5,10 +5,10 @@ file_name: feed_template_snapshot_test.go version: 0.1.0 ---
-
+
-
+
? @@ -16,10 +16,10 @@ version: 0.1.0
@@ -31,7 +31,7 @@ version: 0.1.0
☕ added a new brewer
-
+
Kalita Wave 185 diff --git a/internal/bff/__snapshots__/complete_brew_with_all_fields.snap b/internal/bff/__snapshots__/complete_brew_with_all_fields.snap index e1013f1..45e7011 100644 --- a/internal/bff/__snapshots__/complete_brew_with_all_fields.snap +++ b/internal/bff/__snapshots__/complete_brew_with_all_fields.snap @@ -5,17 +5,17 @@ file_name: feed_template_snapshot_test.go version: 0.1.0 ---
-
+
- +
@@ -27,7 +27,7 @@ version: 0.1.0
☕ added a new brew
-
+
@@ -53,12 +53,12 @@ version: 0.1.0
- + ⭐ 9/10
- + Brewer: @@ -67,13 +67,13 @@ version: 0.1.0
- + Grinder: 1Zpresso JX-Pro (Medium-fine)
- + Pours:
@@ -87,13 +87,13 @@ version: 0.1.0
- + Temp: 93.0°C
- + Time: 3m @@ -102,6 +102,12 @@ version: 0.1.0
"Bright citrus notes with floral aroma"
+
\ No newline at end of file diff --git a/internal/bff/__snapshots__/grinder_item.snap b/internal/bff/__snapshots__/grinder_item.snap index da0e5fe..e922d97 100644 --- a/internal/bff/__snapshots__/grinder_item.snap +++ b/internal/bff/__snapshots__/grinder_item.snap @@ -5,10 +5,10 @@ file_name: feed_template_snapshot_test.go version: 0.1.0 ---
-
+
-
+
? @@ -16,10 +16,10 @@ version: 0.1.0
@@ -31,7 +31,7 @@ version: 0.1.0
⚙️ added a new grinder
-
+
Comandante C40 @@ -39,13 +39,13 @@ version: 0.1.0
- + Type: Hand
- + Burr: Conical diff --git a/internal/bff/__snapshots__/mixed_feed_all_types.snap b/internal/bff/__snapshots__/mixed_feed_all_types.snap index 7533df7..1b486ea 100644 --- a/internal/bff/__snapshots__/mixed_feed_all_types.snap +++ b/internal/bff/__snapshots__/mixed_feed_all_types.snap @@ -5,10 +5,10 @@ file_name: feed_template_snapshot_test.go version: 0.1.0 ---
-
+
-
+
? @@ -16,10 +16,10 @@ version: 0.1.0
@@ -31,7 +31,7 @@ version: 0.1.0
☕ added a new brew
-
+
@@ -57,12 +57,12 @@ version: 0.1.0
- + ⭐ 9/10
- + Brewer: @@ -71,13 +71,13 @@ version: 0.1.0
- + Grinder: 1Zpresso JX-Pro (Medium-fine)
- + Pours:
@@ -91,13 +91,13 @@ version: 0.1.0
- + Temp: 93.0°C
- + Time: 3m @@ -106,19 +106,25 @@ version: 0.1.0
"Bright citrus notes with floral aroma"
+
-
+
- +
@@ -130,7 +136,7 @@ version: 0.1.0
🫘 added a new bean
-
+
Kenya AA @@ -141,19 +147,19 @@ version: 0.1.0
- + Origin: Kenya
- + Roast: Medium
- + Process: Natural @@ -164,10 +170,10 @@ version: 0.1.0
-
+
-
+
? @@ -175,10 +181,10 @@ version: 0.1.0
@@ -190,7 +196,7 @@ version: 0.1.0
🏪 added a new roaster
-
+
Heart Coffee Roasters @@ -198,13 +204,13 @@ version: 0.1.0
- + Location: Portland, OR
-
+
-
+
? @@ -225,7 +231,7 @@ version: 0.1.0
@@ -237,7 +243,7 @@ version: 0.1.0
⚙️ added a new grinder
-
+
Comandante C40 @@ -245,13 +251,13 @@ version: 0.1.0
- + Type: Hand
- + Burr: Conical @@ -262,10 +268,10 @@ version: 0.1.0
-
+
-
+
? @@ -273,10 +279,10 @@ version: 0.1.0
@@ -288,7 +294,7 @@ version: 0.1.0
☕ added a new brewer
-
+
Kalita Wave 185 diff --git a/internal/bff/__snapshots__/profile_with_unsafe_avatar_url.snap b/internal/bff/__snapshots__/profile_with_unsafe_avatar_url.snap index 7a9adf0..3d40fa5 100644 --- a/internal/bff/__snapshots__/profile_with_unsafe_avatar_url.snap +++ b/internal/bff/__snapshots__/profile_with_unsafe_avatar_url.snap @@ -5,10 +5,10 @@ file_name: feed_template_snapshot_test.go version: 0.1.0 ---
-
+
-
+
? @@ -16,10 +16,10 @@ version: 0.1.0
@@ -31,7 +31,7 @@ version: 0.1.0
🫘 added a new bean
-
+
Test Bean @@ -39,19 +39,19 @@ version: 0.1.0
- + Origin: Test Origin
- + Roast: Medium
- + Process: Natural diff --git a/internal/bff/__snapshots__/roaster_item.snap b/internal/bff/__snapshots__/roaster_item.snap index 2ca5a67..6c192e0 100644 --- a/internal/bff/__snapshots__/roaster_item.snap +++ b/internal/bff/__snapshots__/roaster_item.snap @@ -5,17 +5,17 @@ file_name: feed_template_snapshot_test.go version: 0.1.0 ---
-
+
- +
@@ -27,7 +27,7 @@ version: 0.1.0
🏪 added a new roaster
-
+
Heart Coffee Roasters @@ -35,13 +35,13 @@ version: 0.1.0
- + Location: Portland, OR
- + Website: diff --git a/internal/bff/__snapshots__/roaster_with_unsafe_website_url.snap b/internal/bff/__snapshots__/roaster_with_unsafe_website_url.snap index e7e67c3..1983a61 100644 --- a/internal/bff/__snapshots__/roaster_with_unsafe_website_url.snap +++ b/internal/bff/__snapshots__/roaster_with_unsafe_website_url.snap @@ -5,10 +5,10 @@ file_name: feed_template_snapshot_test.go version: 0.1.0 ---
-
+
-
+
? @@ -16,10 +16,10 @@ version: 0.1.0
@@ -31,7 +31,7 @@ version: 0.1.0
🏪 added a new roaster
-
+
Sketchy Roaster diff --git a/internal/bff/__snapshots__/special_characters_in_content.snap b/internal/bff/__snapshots__/special_characters_in_content.snap index 34aab3f..1d8bea2 100644 --- a/internal/bff/__snapshots__/special_characters_in_content.snap +++ b/internal/bff/__snapshots__/special_characters_in_content.snap @@ -5,10 +5,10 @@ file_name: feed_template_snapshot_test.go version: 0.1.0 ---
-
+
-
+
? @@ -16,10 +16,10 @@ version: 0.1.0
@@ -31,7 +31,7 @@ version: 0.1.0
☕ added a new brew
-
+
@@ -39,7 +39,7 @@ version: 0.1.0
- + ⭐ 8/10
@@ -47,6 +47,12 @@ version: 0.1.0
"Notes with "quotes" and <html>tags</html> and 'single quotes'"
+
\ No newline at end of file diff --git a/internal/components/about.templ b/internal/components/about.templ new file mode 100644 index 0000000..f0e8e57 --- /dev/null +++ b/internal/components/about.templ @@ -0,0 +1,110 @@ +package components + +templ AboutContent(isAuthenticated bool) { +
+
+ +

About Arabica

+ ALPHA +
+
+

+ Alpha Software: Arabica is currently in early development. Features may change, and data structures could be modified in future updates. +

+
+
+
+

Your Coffee Journey, Your Data

+

+ Arabica is a coffee brew tracking application built on the AT Protocol, a decentralized social networking protocol. + Unlike traditional apps where your data is locked in a company's database, Arabica stores your brew logs, + coffee beans, and equipment information in your own Personal Data Server (PDS). +

+
+
+

What Makes Arabica Different?

+
    +
  • You own your data - All your brew logs live in your PDS, not our servers
  • +
  • Portable identity - Switch PDS providers anytime without losing your data
  • +
  • Privacy by design - You control who sees your brews
  • +
  • Open protocol - Built on the AT Protocol, the same technology powering Bluesky
  • +
+
+
+

Features

+
+
+

Track Your Brews

+

Log every detail: beans, grind size, water temp, brew time, and tasting notes

+
+
+

Manage Equipment

+

Keep track of your grinders, brewers, beans, and roasters

+
+
+

Community Feed

+

Share your best brews with the community (coming soon: likes and comments!)

+
+
+

Export Your Data

+

Export all your brews anytime in JSON format

+
+
+
+
+

The AT Protocol Advantage

+

+ The AT Protocol is a decentralized social networking protocol that gives users true ownership of their data + and identity. When you use Arabica: +

+
    +
  • Your brew data is stored as ATProto records in collections on your PDS
  • +
  • You authenticate via OAuth with your PDS, not with us
  • +
  • References between records (like linking a brew to a bean) use AT-URIs
  • +
  • Your identity is portable - change PDS providers without losing your account
  • +
+
+
+

Getting Started

+

+ To use Arabica, you'll need an account on a PDS that supports the AT Protocol. + The easiest way is to create a Bluesky account at bsky.app. +

+

+ Once you have an account, simply log in with your handle (e.g., yourname.bsky.social) and start tracking your brews! +

+
+
+

Open Source

+

+ Arabica is open source software. You can view the code, contribute, or even run your own instance. + Visit our GitHub repository to learn more. +

+
+
+
+ if !isAuthenticated { + + Get Started + + } else { + + Log Your Next Brew + + } +
+
+} + +// About is the full About page with layout +templ About(data *LayoutData) { + @Layout(data, AboutContent(data.IsAuthenticated)) +} diff --git a/internal/components/about_templ.go b/internal/components/about_templ.go new file mode 100644 index 0000000..e87b211 --- /dev/null +++ b/internal/components/about_templ.go @@ -0,0 +1,85 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.960 +package components + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func AboutContent(isAuthenticated bool) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

About Arabica

ALPHA

Alpha Software: Arabica is currently in early development. Features may change, and data structures could be modified in future updates.

Your Coffee Journey, Your Data

Arabica is a coffee brew tracking application built on the AT Protocol, a decentralized social networking protocol. Unlike traditional apps where your data is locked in a company's database, Arabica stores your brew logs, coffee beans, and equipment information in your own Personal Data Server (PDS).

What Makes Arabica Different?

  • You own your data - All your brew logs live in your PDS, not our servers
  • Portable identity - Switch PDS providers anytime without losing your data
  • Privacy by design - You control who sees your brews
  • Open protocol - Built on the AT Protocol, the same technology powering Bluesky

Features

Track Your Brews

Log every detail: beans, grind size, water temp, brew time, and tasting notes

Manage Equipment

Keep track of your grinders, brewers, beans, and roasters

Community Feed

Share your best brews with the community (coming soon: likes and comments!)

Export Your Data

Export all your brews anytime in JSON format

The AT Protocol Advantage

The AT Protocol is a decentralized social networking protocol that gives users true ownership of their data and identity. When you use Arabica:

  • Your brew data is stored as ATProto records in collections on your PDS
  • You authenticate via OAuth with your PDS, not with us
  • References between records (like linking a brew to a bean) use AT-URIs
  • Your identity is portable - change PDS providers without losing your account

Getting Started

To use Arabica, you'll need an account on a PDS that supports the AT Protocol. The easiest way is to create a Bluesky account at bsky.app.

Once you have an account, simply log in with your handle (e.g., yourname.bsky.social) and start tracking your brews!

Open Source

Arabica is open source software. You can view the code, contribute, or even run your own instance. Visit our GitHub repository to learn more.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !isAuthenticated { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "Get Started") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "Log Your Next Brew") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// About is the full About page with layout +func About(data *LayoutData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var2 := templ.GetChildren(ctx) + if templ_7745c5c3_Var2 == nil { + templ_7745c5c3_Var2 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = Layout(data, AboutContent(data.IsAuthenticated)).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/components/about_test.go b/internal/components/about_test.go new file mode 100644 index 0000000..9a7332d --- /dev/null +++ b/internal/components/about_test.go @@ -0,0 +1,81 @@ +package components + +import ( + "arabica/internal/bff" + "bytes" + "context" + "strings" + "testing" +) + +func TestAboutComponent(t *testing.T) { + // Create test data + data := &LayoutData{ + Title: "About", + IsAuthenticated: false, + UserDID: "", + UserProfile: nil, + } + + // Render the component + var buf bytes.Buffer + err := About(data).Render(context.Background(), &buf) + if err != nil { + t.Fatalf("Failed to render About component: %v", err) + } + + html := buf.String() + + // Verify key content is present + tests := []struct { + name string + content string + }{ + {"title", "About Arabica"}, + {"alpha badge", "ALPHA"}, + {"main heading", "Your Coffee Journey, Your Data"}, + {"AT Protocol mention", "AT Protocol"}, + {"footer", "Your brew, your data"}, + {"get started button", "Get Started"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if !strings.Contains(html, tt.content) { + t.Errorf("Expected HTML to contain %q, but it was not found", tt.content) + } + }) + } +} + +func TestAboutComponentAuthenticated(t *testing.T) { + // Create test data with authenticated user + data := &LayoutData{ + Title: "About", + IsAuthenticated: true, + UserDID: "did:plc:test123", + UserProfile: &bff.UserProfile{ + Handle: "testuser.bsky.social", + DisplayName: "Test User", + Avatar: "", + }, + } + + // Render the component + var buf bytes.Buffer + err := About(data).Render(context.Background(), &buf) + if err != nil { + t.Fatalf("Failed to render About component: %v", err) + } + + html := buf.String() + + // When authenticated, should show "Log Your Next Brew" instead of "Get Started" + if !strings.Contains(html, "Log Your Next Brew") { + t.Error("Expected authenticated view to show 'Log Your Next Brew' button") + } + + if strings.Contains(html, "Get Started") { + t.Error("Expected authenticated view NOT to show 'Get Started' button") + } +} diff --git a/internal/components/brew_form.templ b/internal/components/brew_form.templ new file mode 100644 index 0000000..85511d0 --- /dev/null +++ b/internal/components/brew_form.templ @@ -0,0 +1,125 @@ +package components + +import "arabica/internal/models" + +type BrewFormProps struct { + // Brew being edited; nil if creating new + Brew *models.Brew + + // Collections for selects + Beans []models.Bean + Grinders []models.Grinder + Brewers []models.Brewer + + // Derived JSON or values for client Alpine initialization + PoursJSON string // optional, only if editing + + // Back button + BackURL string +} + +templ BrewFormPage(layout *LayoutData, props BrewFormProps) { + @Layout(layout, BrewFormCard(props)) +} + + +templ BrewFormCard(props BrewFormProps) { +
+
+ BrewHeader(props) + BrewForm(props) +
+
+} + +templ BrewHeader(props BrewFormProps) { +
+ BackButton("/brews") + if props.Brew != nil { +

Edit Brew

+ } else { +

New Brew

+ } +
+} + +templ BrewForm(props BrewFormProps) { +
0 { + data-pours={props.Brew.PoursJSON} + } + > + BeanSelect(props) + CoffeeAmount(props) + GrinderSelect(props) + GrindSize(props) + BrewerSelect(props) + WaterAmount(props) + PoursEditor(props) + Temperature(props) + BrewTime(props) + TastingNotes(props) + RatingInput(props) + SubmitButton(props) +
+} + +templ EntitySelect( + label string, + name string, + options []Entity, + selected string, + onNew string, +) { +
+ +
+ + +
+
+} + +templ PoursEditor(props BrewFormProps) { +
+
+ + +
+ + +
+} + +templ PourRow(index string) { +
+ + + +
+} + +templ RatingInput(props BrewFormProps) { +
+ +
/10
+
+} diff --git a/internal/components/footer.templ b/internal/components/footer.templ new file mode 100644 index 0000000..f856bb0 --- /dev/null +++ b/internal/components/footer.templ @@ -0,0 +1,19 @@ +package components + +templ Footer() { + +} diff --git a/internal/components/footer_templ.go b/internal/components/footer_templ.go new file mode 100644 index 0000000..26b91c7 --- /dev/null +++ b/internal/components/footer_templ.go @@ -0,0 +1,40 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.960 +package components + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func Footer() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/components/header.templ b/internal/components/header.templ new file mode 100644 index 0000000..2b439c2 --- /dev/null +++ b/internal/components/header.templ @@ -0,0 +1,109 @@ +package components + +import "arabica/internal/bff" + +templ Header(isAuthenticated bool, userProfile *bff.UserProfile) { + +} + +// Helper function to get profile identifier (handle or DID) +func getProfileIdentifier(userProfile *bff.UserProfile) string { + if userProfile != nil && userProfile.Handle != "" { + return userProfile.Handle + } + if userProfile != nil { + // In the original template, it uses UserDID, but we don't have it here + // We'll need to pass it separately or handle it differently + return "" + } + return "" +} + +// safeAvatarHTML renders the avatar image if the URL is safe +templ safeAvatarHTML(avatarURL string, displayName string) { + // We need to call the SafeAvatarURL helper function from bff + // For now, we'll do a simple check + if avatarURL != "" && (len(avatarURL) > 8 && avatarURL[:8] == "https://") { + + } else { +
+ + if displayName != "" { + { displayName[:1] } + } else { + ? + } + +
+ } +} diff --git a/internal/components/header_templ.go b/internal/components/header_templ.go new file mode 100644 index 0000000..141d0c2 --- /dev/null +++ b/internal/components/header_templ.go @@ -0,0 +1,233 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.960 +package components + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "arabica/internal/bff" +) + +func Header(isAuthenticated bool, userProfile *bff.UserProfile) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// Helper function to get profile identifier (handle or DID) +func getProfileIdentifier(userProfile *bff.UserProfile) string { + if userProfile != nil && userProfile.Handle != "" { + return userProfile.Handle + } + if userProfile != nil { + // In the original template, it uses UserDID, but we don't have it here + // We'll need to pass it separately or handle it differently + return "" + } + return "" +} + +// safeAvatarHTML renders the avatar image if the URL is safe +func safeAvatarHTML(avatarURL string, displayName string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var7 := templ.GetChildren(ctx) + if templ_7745c5c3_Var7 == nil { + templ_7745c5c3_Var7 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + if avatarURL != "" && (len(avatarURL) > 8 && avatarURL[:8] == "https://") { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\"\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if displayName != "" { + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(displayName[:1]) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/components/header.templ`, Line: 104, Col: 22} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "?") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/components/layout.templ b/internal/components/layout.templ new file mode 100644 index 0000000..7d08cc2 --- /dev/null +++ b/internal/components/layout.templ @@ -0,0 +1,52 @@ +package components + +import "arabica/internal/bff" + +// LayoutData contains all the data needed for the layout +type LayoutData struct { + Title string + IsAuthenticated bool + UserDID string + UserProfile *bff.UserProfile +} + +templ Layout(data *LayoutData, content templ.Component) { + + + + + + + + + + + { data.Title } - Arabica + + + + + + + + + if data.IsAuthenticated { + + } + + + + + @Header(data.IsAuthenticated, data.UserProfile) +
+ @content +
+ @Footer() + + +} diff --git a/internal/components/layout_templ.go b/internal/components/layout_templ.go new file mode 100644 index 0000000..7b9ca6e --- /dev/null +++ b/internal/components/layout_templ.go @@ -0,0 +1,122 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.960 +package components + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "arabica/internal/bff" +) + +// LayoutData contains all the data needed for the layout +type LayoutData struct { + Title string + IsAuthenticated bool + UserDID string + UserProfile *bff.UserProfile +} + +func Layout(data *LayoutData, content templ.Component) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(data.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/components/layout.templ`, Line: 26, Col: 22} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Arabica") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.IsAuthenticated { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Header(data.IsAuthenticated, data.UserProfile).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = content.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Footer().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 7f9c169..f8700c1 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -10,6 +10,7 @@ import ( "arabica/internal/atproto" "arabica/internal/bff" + "arabica/internal/components" "arabica/internal/database" "arabica/internal/feed" "arabica/internal/models" @@ -1277,14 +1278,15 @@ func (h *Handler) HandleAbout(w http.ResponseWriter, r *http.Request) { userProfile = h.getUserProfile(r.Context(), didStr) } - data := &bff.PageData{ + data := &components.LayoutData{ Title: "About", IsAuthenticated: isAuthenticated, UserDID: didStr, UserProfile: userProfile, } - if err := bff.RenderTemplate(w, r, "about.tmpl", data); err != nil { + // Use templ component + if err := components.About(data).Render(r.Context(), w); err != nil { http.Error(w, "Failed to render page", http.StatusInternalServerError) log.Error().Err(err).Msg("Failed to render about page") } diff --git a/templates/404.tmpl b/templates/404.tmpl index f509142..d600ef1 100644 --- a/templates/404.tmpl +++ b/templates/404.tmpl @@ -1,12 +1,12 @@ {{define "content"}}
-
-
404
-

Page Not Found

-

The page you're looking for doesn't exist or has been moved.

- - Back to Home - -
+
+
404
+

Page Not Found

+

The page you're looking for doesn't exist or has been moved.

+ + Back to Home + +
{{end}} diff --git a/templates/brew_form.tmpl b/templates/brew_form.tmpl index 176a111..6dbd648 100644 --- a/templates/brew_form.tmpl +++ b/templates/brew_form.tmpl @@ -1,278 +1,281 @@ {{define "content"}} - + + + -
-
- -
- -

- {{if .Brew}}Edit Brew{{else}}New Brew{{end}} -

+
+
+ + +
+ +

+ {{if .Brew}}Edit Brew{{else}}New Brew{{end}} +

+
+ +
+ + +
+ +
+ +
- - + + +
+ + +

Amount of ground coffee used

+
+ + +
+ +
+ - - {{if .Beans}} - {{range .Beans}} - - {{end}} - {{else if .Brew}} - - - {{end}} - - -
-
- - -
- - -

Amount of ground coffee used

-
- - -
- -
- - -
-
- - -
- - -

Enter a number (grinder setting) or description (e.g. "Medium", "Fine")

-
- - -
- -
- - -
-
- - -
- - -

Total water used (or leave empty if using pours below)

-
- - -
-
- - -
-

Track individual pours for bloom and subsequent additions

- -
- -
-
- - -
- - -
- - -
- + + +
+
+ + +
+ + +

Enter a number (grinder setting) or description (e.g. "Medium", "Fine")

+
+ + +
+ +
+ + +
+
+ + +
+ + +

Total water used (or leave empty if using pours below)

+
+ + +
+
+ + +
+

Track individual pours for bloom and subsequent additions

+ +
+ +
+
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ /10 +
+
+ + +
+ +
+
+
+ + + {{template "bean_form_modal" .}} + {{template "grinder_form_modal" .}} + {{template "brewer_form_modal" .}}
{{end}} diff --git a/templates/brew_list.tmpl b/templates/brew_list.tmpl index ad0a7fc..a0393b8 100644 --- a/templates/brew_list.tmpl +++ b/templates/brew_list.tmpl @@ -1,58 +1,58 @@ {{define "content"}}
-
-

Your Brews

- - + New Brew - -
+
+

Your Brews

+ + + New Brew + +
-
- -
- - - - - - - - - - - - - {{range iterate 5}} - - - - - - - - - {{end}} - -
DateBeanRoasterMethodRatingActions
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+ +
+ + + + + + + + + + + + + {{range iterate 5}} + + + + + + + + + {{end}} + +
DateBeanRoasterMethodRatingActions
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{end}} diff --git a/templates/brew_view.tmpl b/templates/brew_view.tmpl index 43ce1db..7ba5f33 100644 --- a/templates/brew_view.tmpl +++ b/templates/brew_view.tmpl @@ -1,6 +1,6 @@ {{define "content"}}
-
+
@@ -10,7 +10,7 @@ {{if .IsOwnProfile}}
+ class="inline-flex items-center btn-secondary"> Edit - + +
diff --git a/templates/partials/brew_list_content.tmpl b/templates/partials/brew_list_content.tmpl index d45ac6d..ad953e2 100644 --- a/templates/partials/brew_list_content.tmpl +++ b/templates/partials/brew_list_content.tmpl @@ -1,145 +1,145 @@ {{define "brew_list_content"}} {{if not .Brews}} -
- {{if $.IsOwnProfile}} -

No brews yet! Start tracking your coffee journey.

- - Add Your First Brew - - {{else}} -

No brews yet.

- {{end}} +
+ {{if $.IsOwnProfile}} +

No brews yet! Start tracking your coffee journey.

+ + Add Your First Brew + + {{else}} +

No brews yet.

+ {{end}}
{{else}} -
- - - - - - - - - - - - - - {{range .Brews}} - - - - - - - - - - - - - - - - - - - - - - +
+
DateBeanBrewerVariablesNotesRatingActions
-
{{.CreatedAt.Format "Jan 2"}}
-
{{.CreatedAt.Format "2006"}}
-
- {{if .Bean}} -
- {{if .Bean.Name}}{{.Bean.Name}}{{else}}{{.Bean.Origin}}{{end}} -
- {{if and .Bean.Roaster .Bean.Roaster.Name}} -
- {{.Bean.Roaster.Name}} -
- {{end}} -
- {{if .Bean.Origin}}📍 {{.Bean.Origin}}{{end}} - {{if .Bean.RoastLevel}}🔥 {{.Bean.RoastLevel}}{{end}} - {{if hasValue .CoffeeAmount}}⚖️ {{.CoffeeAmount}}g{{end}} -
- {{else}} - - - {{end}} -
- {{if .BrewerObj}} -
{{.BrewerObj.Name}}
- {{else if .Method}} -
{{.Method}}
- {{else}} - - - {{end}} -
-
- {{if .GrinderObj}} -
Grinder: {{.GrinderObj.Name}}{{if .GrindSize}} ({{.GrindSize}}){{end}}
- {{else if .GrindSize}} -
Grind: {{.GrindSize}}
- {{end}} - - {{if hasTemp .Temperature}} -
Temp: {{formatTemp .Temperature}}
- {{end}} - - {{if .Pours}} -
Pours:
- {{range .Pours}} -
• {{.WaterAmount}}g @ {{formatTime .TimeSeconds}}
- {{end}} - {{else if hasValue .WaterAmount}} -
Water: {{.WaterAmount}}g
- {{end}} - - {{if hasValue .TimeSeconds}} -
Time: {{formatTime .TimeSeconds}}
- {{end}} -
-
- {{if .TastingNotes}} -
{{.TastingNotes}}
- {{else}} - - - {{end}} -
- {{if hasValue .Rating}} - - ⭐ {{formatRating .Rating}} - - {{else}} - - - {{end}} - - {{if $.IsOwnProfile}} - View - {{else if $.ProfileHandle}} - View - {{else}} - View - {{end}} - {{if $.IsOwnProfile}} - Edit - - {{end}} -
+ + + + + + + + + + + + + {{range .Brews}} + + + + + + + + + + + + -
DateBeanBrewerVariablesNotesRatingActions
+
{{.CreatedAt.Format "Jan 2"}}
+
{{.CreatedAt.Format "2006"}}
+
+ {{if .Bean}} +
+ {{if .Bean.Name}}{{.Bean.Name}}{{else}}{{.Bean.Origin}}{{end}} +
+ {{if and .Bean.Roaster .Bean.Roaster.Name}} +
+ {{.Bean.Roaster.Name}} +
+ {{end}} +
+ {{if .Bean.Origin}}📍 {{.Bean.Origin}}{{end}} + {{if .Bean.RoastLevel}}🔥 {{.Bean.RoastLevel}}{{end}} + {{if hasValue .CoffeeAmount}}⚖️ {{.CoffeeAmount}}g{{end}} +
+ {{else}} + - + {{end}} +
+ {{if .BrewerObj}} +
{{.BrewerObj.Name}}
+ {{else if .Method}} +
{{.Method}}
+ {{else}} + - + {{end}} +
+
+ {{if .GrinderObj}} +
Grinder: {{.GrinderObj.Name}}{{if .GrindSize}} ({{.GrindSize}}){{end}}
+ {{else if .GrindSize}} +
Grind: {{.GrindSize}}
{{end}} -
+ + {{if hasTemp .Temperature}} +
Temp: {{formatTemp .Temperature}}
+ {{end}} + + {{if .Pours}} +
Pours:
+ {{range .Pours}} +
• {{.WaterAmount}}g @ {{formatTime .TimeSeconds}}
+ {{end}} + {{else if hasValue .WaterAmount}} +
Water: {{.WaterAmount}}g
+ {{end}} + + {{if hasValue .TimeSeconds}} +
Time: {{formatTime .TimeSeconds}}
+ {{end}} +
+ + + + + {{if .TastingNotes}} +
{{.TastingNotes}}
+ {{else}} + - + {{end}} + + + + + {{if hasValue .Rating}} + + ⭐ {{formatRating .Rating}} + + {{else}} + - + {{end}} + + + + + {{if $.IsOwnProfile}} + View + {{else if $.ProfileHandle}} + View + {{else}} + View + {{end}} + {{if $.IsOwnProfile}} + Edit + + {{end}} + + + {{end}} + +
{{end}} {{end}} diff --git a/templates/partials/brewer_form_modal.tmpl b/templates/partials/brewer_form_modal.tmpl index 2c7c4aa..ef1142f 100644 --- a/templates/partials/brewer_form_modal.tmpl +++ b/templates/partials/brewer_form_modal.tmpl @@ -1,22 +1,17 @@ {{define "brewer_form_modal"}} -
-
-

-
- - - -
- - -
-
+ {{end}} diff --git a/templates/partials/feed.tmpl b/templates/partials/feed.tmpl index 4a1e299..5bebc84 100644 --- a/templates/partials/feed.tmpl +++ b/templates/partials/feed.tmpl @@ -1,217 +1,217 @@ {{define "feed"}}
- {{if .FeedItems}} - {{range .FeedItems}} -
- -
- - {{if .Author.Avatar}} - {{$safeAvatar := safeAvatarURL .Author.Avatar}} - {{if $safeAvatar}} - - {{else}} -
- ? -
- {{end}} - {{else}} -
- ? -
- {{end}} -
-
-
- {{if .Author.DisplayName}} - {{.Author.DisplayName}} - {{end}} - @{{.Author.Handle}} -
- {{.TimeAgo}} -
+ {{if .FeedItems}} + {{range .FeedItems}} +
+ + + + +
+ {{.Action}} +
- -
- {{.Action}} + + {{if eq .RecordType "brew"}} + +
+ +
+
+ {{if .Brew.Bean}} +
+ {{if .Brew.Bean.Name}}{{.Brew.Bean.Name}}{{else}}{{.Brew.Bean.Origin}}{{end}} +
+ {{if and .Brew.Bean.Roaster .Brew.Bean.Roaster.Name}} +
+ 🏭 {{.Brew.Bean.Roaster.Name}} +
+ {{end}} +
+ {{if .Brew.Bean.Origin}}📍 {{.Brew.Bean.Origin}}{{end}} + {{if .Brew.Bean.RoastLevel}}🔥 {{.Brew.Bean.RoastLevel}}{{end}} + {{if .Brew.Bean.Process}}🌱 {{.Brew.Bean.Process}}{{end}} + {{if hasValue .Brew.CoffeeAmount}}⚖️ {{.Brew.CoffeeAmount}}g{{end}} +
+ {{end}}
+ {{if hasValue .Brew.Rating}} + + ⭐ {{.Brew.Rating}}/10 + + {{end}} +
- - {{if eq .RecordType "brew"}} - -
- -
-
- {{if .Brew.Bean}} -
- {{if .Brew.Bean.Name}}{{.Brew.Bean.Name}}{{else}}{{.Brew.Bean.Origin}}{{end}} -
- {{if and .Brew.Bean.Roaster .Brew.Bean.Roaster.Name}} -
- 🏭 {{.Brew.Bean.Roaster.Name}} -
- {{end}} -
- {{if .Brew.Bean.Origin}}📍 {{.Brew.Bean.Origin}}{{end}} - {{if .Brew.Bean.RoastLevel}}🔥 {{.Brew.Bean.RoastLevel}}{{end}} - {{if .Brew.Bean.Process}}🌱 {{.Brew.Bean.Process}}{{end}} - {{if hasValue .Brew.CoffeeAmount}}⚖️ {{.Brew.CoffeeAmount}}g{{end}} -
- {{end}} -
- {{if hasValue .Brew.Rating}} - - ⭐ {{.Brew.Rating}}/10 - - {{end}} -
- - - {{if or .Brew.BrewerObj .Brew.Method}} -
- Brewer: - - {{if .Brew.BrewerObj}}{{.Brew.BrewerObj.Name}}{{else if .Brew.Method}}{{.Brew.Method}}{{end}} - -
- {{end}} - - -
- {{if .Brew.GrinderObj}} -
- Grinder: {{.Brew.GrinderObj.Name}}{{if .Brew.GrindSize}} ({{.Brew.GrindSize}}){{end}} -
- {{else if .Brew.GrindSize}} -
- Grind: {{.Brew.GrindSize}} -
- {{end}} - {{if .Brew.Pours}} -
- Pours: - {{range .Brew.Pours}} -
• {{.WaterAmount}}g @ {{formatTime .TimeSeconds}}
- {{end}} -
- {{else if hasValue .Brew.WaterAmount}} -
- Water: {{.Brew.WaterAmount}}g -
- {{end}} - {{if hasTemp .Brew.Temperature}} -
- Temp: {{formatTemp .Brew.Temperature}} -
- {{end}} - {{if hasValue .Brew.TimeSeconds}} -
- Time: {{formatTime .Brew.TimeSeconds}} -
- {{end}} -
+ + {{if or .Brew.BrewerObj .Brew.Method}} +
+ Brewer: + + {{if .Brew.BrewerObj}}{{.Brew.BrewerObj.Name}}{{else if .Brew.Method}}{{.Brew.Method}}{{end}} + +
+ {{end}} - {{if .Brew.TastingNotes}} -
- "{{.Brew.TastingNotes}}" -
- {{end}} - - - + +
+ {{if .Brew.GrinderObj}} +
+ Grinder: {{.Brew.GrinderObj.Name}}{{if .Brew.GrindSize}} ({{.Brew.GrindSize}}){{end}}
- {{else if eq .RecordType "bean"}} - -
-
- - {{if .Bean.Name}}{{.Bean.Name}}{{else}}{{.Bean.Origin}}{{end}} - - {{if and .Bean.Roaster .Bean.Roaster.Name}} - from {{.Bean.Roaster.Name}} - {{end}} -
-
- {{if .Bean.Origin}} -
Origin: {{.Bean.Origin}}
- {{end}} - {{if .Bean.RoastLevel}} -
Roast: {{.Bean.RoastLevel}}
- {{end}} - {{if .Bean.Process}} -
Process: {{.Bean.Process}}
- {{end}} - {{if .Bean.Description}} -
"{{.Bean.Description}}"
- {{end}} -
+ {{else if .Brew.GrindSize}} +
+ Grind: {{.Brew.GrindSize}}
- {{else if eq .RecordType "roaster"}} - -
-
- {{.Roaster.Name}} -
-
- {{if .Roaster.Location}} -
Location: {{.Roaster.Location}}
- {{end}} - {{if .Roaster.Website}} - {{$safeWebsite := safeWebsiteURL .Roaster.Website}} - {{if $safeWebsite}} - - {{end}} - {{end}} -
+ {{end}} + {{if .Brew.Pours}} +
+ Pours: + {{range .Brew.Pours}} +
• {{.WaterAmount}}g @ {{formatTime .TimeSeconds}}
+ {{end}} +
+ {{else if hasValue .Brew.WaterAmount}} +
+ Water: {{.Brew.WaterAmount}}g
- {{else if eq .RecordType "grinder"}} - -
-
- {{.Grinder.Name}} -
-
- {{if .Grinder.GrinderType}} -
Type: {{.Grinder.GrinderType}}
- {{end}} - {{if .Grinder.BurrType}} -
Burr: {{.Grinder.BurrType}}
- {{end}} - {{if .Grinder.Notes}} -
"{{.Grinder.Notes}}"
- {{end}} -
+ {{end}} + {{if hasTemp .Brew.Temperature}} +
+ Temp: {{formatTemp .Brew.Temperature}}
- {{else if eq .RecordType "brewer"}} - -
-
- {{.Brewer.Name}} -
- {{if .Brewer.Description}} -
"{{.Brewer.Description}}"
- {{end}} + {{end}} + {{if hasValue .Brew.TimeSeconds}} +
+ Time: {{formatTime .Brew.TimeSeconds}}
{{end}} +
+ + {{if .Brew.TastingNotes}} +
+ "{{.Brew.TastingNotes}}" +
+ {{end}} + + +
- {{end}} - - - - - - {{else}} -
-

No activity in the feed yet.

-

Be the first to add something!

+ {{else if eq .RecordType "bean"}} + +
+
+ + {{if .Bean.Name}}{{.Bean.Name}}{{else}}{{.Bean.Origin}}{{end}} + + {{if and .Bean.Roaster .Bean.Roaster.Name}} + from {{.Bean.Roaster.Name}} + {{end}} +
+
+ {{if .Bean.Origin}} +
Origin: {{.Bean.Origin}}
+ {{end}} + {{if .Bean.RoastLevel}} +
Roast: {{.Bean.RoastLevel}}
+ {{end}} + {{if .Bean.Process}} +
Process: {{.Bean.Process}}
+ {{end}} + {{if .Bean.Description}} +
"{{.Bean.Description}}"
+ {{end}} +
+
+ {{else if eq .RecordType "roaster"}} + +
+
+ {{.Roaster.Name}} +
+
+ {{if .Roaster.Location}} +
Location: {{.Roaster.Location}}
+ {{end}} + {{if .Roaster.Website}} + {{$safeWebsite := safeWebsiteURL .Roaster.Website}} + {{if $safeWebsite}} + + {{end}} + {{end}} +
+
+ {{else if eq .RecordType "grinder"}} + +
+
+ {{.Grinder.Name}} +
+
+ {{if .Grinder.GrinderType}} +
Type: {{.Grinder.GrinderType}}
+ {{end}} + {{if .Grinder.BurrType}} +
Burr: {{.Grinder.BurrType}}
+ {{end}} + {{if .Grinder.Notes}} +
"{{.Grinder.Notes}}"
+ {{end}} +
+
+ {{else if eq .RecordType "brewer"}} + +
+
+ {{.Brewer.Name}} +
+ {{if .Brewer.Description}} +
"{{.Brewer.Description}}"
+ {{end}}
{{end}} +
+ {{end}} + + + + + + {{else}} +
+

No activity in the feed yet.

+

Be the first to add something!

+
+ {{end}}
{{end}} diff --git a/templates/partials/footer.tmpl b/templates/partials/footer.tmpl index 7d61476..6aa39a5 100644 --- a/templates/partials/footer.tmpl +++ b/templates/partials/footer.tmpl @@ -12,11 +12,6 @@ AT Protocol
-
{{end}} diff --git a/templates/partials/grinder_form_modal.tmpl b/templates/partials/grinder_form_modal.tmpl index 6716568..f9d98cd 100644 --- a/templates/partials/grinder_form_modal.tmpl +++ b/templates/partials/grinder_form_modal.tmpl @@ -1,29 +1,25 @@ {{define "grinder_form_modal"}} -
-
-

+ @@ -15,22 +15,22 @@ No beans yet. Add your first bean to get started!
{{else}} -
- - +
+
+ - - - - - - - + + + + + + + - + {{range .Beans}} - + {{end}} @@ -63,7 +63,7 @@

Roasters

@@ -73,19 +73,19 @@ No roasters yet. Add your first roaster! {{else}} -
-
Name📍 Origin☕ Roaster🔥 Roast Level🌱 Process📝 DescriptionActionsName📍 Origin☕ Roaster🔥 Roast Level🌱 Process📝 DescriptionActions
{{.Name}} {{.Origin}} @@ -45,9 +45,9 @@ {{.Description}} + class="btn-link">Edit + class="btn-danger">Delete
- +
+
+ - - - - + + + + - + {{range .Roasters}} - + {{end}} @@ -115,7 +115,7 @@

Grinders

@@ -125,29 +125,29 @@ No grinders yet. Add your first grinder! {{else}} -
-
Name📍 Location🌐 WebsiteActionsName📍 Location🌐 WebsiteActions
{{.Name}} {{.Location}} @@ -97,9 +97,9 @@ + class="btn-link">Edit + class="btn-danger">Delete
- +
+
+ - - - - - + + + + + - + {{range .Grinders}} - + {{end}} @@ -162,7 +162,7 @@

Brewers

@@ -172,17 +172,17 @@ No brewers yet. Add your first brewer! {{else}} -
-
Name🔧 Grinder Type💎 Burr Type📝 NotesActionsName🔧 Grinder Type💎 Burr Type📝 NotesActions
{{.Name}} {{.GrinderType}} {{.BurrType}} {{.Notes}} + class="btn-link">Edit + class="btn-danger">Delete
- +
+
+ - - - - + + + + - + {{range .Brewers}} {{.Description}} {{end}} diff --git a/templates/partials/new_roaster_form.tmpl b/templates/partials/new_roaster_form.tmpl index b323d25..39d814c 100644 --- a/templates/partials/new_roaster_form.tmpl +++ b/templates/partials/new_roaster_form.tmpl @@ -1,20 +1,15 @@ {{define "new_roaster_form"}} -
-
-

+
Name🔧 Type📝 DescriptionActionsName🔧 Type📝 DescriptionActions
+ class="btn-link">Edit + class="btn-danger">Delete
- +
+
+ - - - - - - + + + + + + - + {{range .Beans}} - + @@ -63,7 +63,7 @@ {{if $.IsOwnProfile}}
- @@ -76,18 +76,18 @@ {{if .Roasters}}

🏭 Favorite Roasters

-
-
Name☕ Roaster📍 Origin🔥 Roast🌱 Process📝 DescriptionName☕ Roaster📍 Origin🔥 Roast🌱 Process📝 Description
{{if .Name}}{{.Name}}{{else}}{{.Origin}}{{end}}
- +
+
+ - - - + + + - + {{range .Roasters}} - +
Name📍 Location🌐 WebsiteName📍 Location🌐 Website
{{.Name}} {{if .Location}}{{.Location}}{{else}}-{{end}} @@ -111,7 +111,7 @@ {{if $.IsOwnProfile}}
- @@ -133,19 +133,19 @@ {{if .Grinders}}

⚙️ Grinders

-
- - +
+
+ - - - - + + + + - + {{range .Grinders}} - +
Name🔧 Type💎 Burrs📝 NotesName🔧 Type💎 Burrs📝 Notes
{{.Name}} {{if .GrinderType}}{{.GrinderType}}{{else}}-{{end}} @@ -163,7 +163,7 @@ {{if $.IsOwnProfile}}
- @@ -176,18 +176,18 @@ {{if .Brewers}}

☕ Brewers

-
- - +
+
+ - - - + + + - + {{range .Brewers}} - +
Name🔧 Type📝 DescriptionName🔧 Type📝 Description
{{.Name}} {{if .BrewerType}}{{.BrewerType}}{{else}}-{{end}} @@ -202,7 +202,7 @@ {{if $.IsOwnProfile}}
- diff --git a/templates/profile.tmpl b/templates/profile.tmpl index 3b0781d..82e5e7c 100644 --- a/templates/profile.tmpl +++ b/templates/profile.tmpl @@ -1,7 +1,8 @@ {{define "content"}} {{if .IsOwnProfile}} - + + {{end}} diff --git a/web/static/css/style.css b/web/static/css/style.css index 4c0f38a..c456c53 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -10,7 +10,7 @@ min-height: 44px; min-width: 44px; } - + /* Prevent iOS zoom on input focus */ @media (max-width: 768px) { input, @@ -21,6 +21,171 @@ } } +@layer components { + /* Cards and Containers */ + .card { + @apply bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300; + } + + .card-inner { + @apply p-6; + } + + /* Buttons */ + .btn { + @apply inline-flex items-center justify-center px-4 py-2 rounded-lg font-medium transition-colors cursor-pointer; + } + + .btn-primary { + @apply btn bg-gradient-to-br from-brown-700 to-brown-900 text-white hover:from-brown-800 hover:to-brown-900 shadow-md; + } + + .btn-secondary { + @apply btn bg-brown-300 text-brown-900 hover:bg-brown-400; + } + + .btn-link { + @apply text-brown-700 hover:text-brown-900 font-medium underline transition-colors cursor-pointer; + } + + .btn-danger { + @apply text-red-600 hover:text-red-800 font-medium underline transition-colors cursor-pointer; + } + + /* Forms */ + .form-label { + @apply block text-sm font-medium text-brown-900 mb-2; + } + + .form-input { + @apply rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-2 px-3 bg-white; + } + + .form-input-lg { + @apply form-input py-3 px-4; + } + + .form-select { + @apply form-input truncate max-w-full; + } + + .form-textarea { + @apply form-input min-h-[100px]; + } + + /* Tables */ + .table-container { + @apply bg-gradient-to-br from-brown-100 to-brown-200 rounded-lg shadow-md overflow-hidden border border-brown-300; + } + + .table { + @apply min-w-full divide-y divide-brown-300; + } + + .table-header { + @apply bg-brown-200; + } + + .table-th { + @apply px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider; + } + + .table-body { + @apply bg-brown-100 divide-y divide-brown-200; + } + + .table-row { + @apply hover:bg-brown-100 transition-colors; + } + + .table-td { + @apply px-6 py-4 whitespace-nowrap text-sm text-brown-800; + } + + /* Modals */ + .modal-backdrop { + @apply fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-4; + } + + .modal-content { + @apply bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-2xl p-6 max-w-md w-full border border-brown-300 max-h-[90vh] overflow-y-auto; + } + + .modal-title { + @apply text-xl font-semibold text-brown-900 mb-4; + } + + /* Typography */ + .section-title { + @apply text-2xl font-bold text-brown-900 mb-4; + } + + .page-title { + @apply text-3xl font-bold text-brown-900; + } + + /* Feed Components */ + .feed-card { + @apply 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; + } + + .feed-content-box { + @apply bg-white/60 backdrop-blur rounded-lg p-4 border border-brown-200; + } + + .feed-content-box-sm { + @apply bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200; + } + + /* Avatar */ + .avatar { + @apply w-10 h-10 rounded-full object-cover; + } + + .avatar-placeholder { + @apply w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center; + } + + .avatar-ring { + @apply hover:ring-2 hover:ring-brown-600 transition; + } + + /* Text Utilities */ + .text-helper { + @apply text-sm text-brown-700 mt-1; + } + + .text-meta { + @apply text-xs text-brown-600; + } + + .text-meta-sm { + @apply text-sm text-brown-600; + } + + .text-label { + @apply text-brown-600; + } + + /* Badges */ + .badge-rating { + @apply inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-900 flex-shrink-0; + } + + .badge-rating-sm { + @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-900; + } + + /* Links */ + .link { + @apply text-brown-700 hover:text-brown-900 hover:underline transition-colors; + } + + .link-bold { + @apply font-medium text-brown-700 hover:text-brown-900 hover:underline transition-colors; + } +} + /* HTMX loading state */ .htmx-swapping { opacity: 0; diff --git a/web/static/js/brew-form.js b/web/static/js/brew-form.js index 676ecac..eb5d43d 100644 --- a/web/static/js/brew-form.js +++ b/web/static/js/brew-form.js @@ -1,42 +1,29 @@ /** * Alpine.js component for the brew form * Manages pours, new entity modals, and form state - * Populates dropdowns from client-side cache for faster UX + * Uses shared entity-manager and dropdown-manager modules */ function brewForm() { return { - // Modal state (matching manage page) - showBeanForm: false, - showGrinderForm: false, - showBrewerForm: false, - editingBean: null, - editingGrinder: null, - editingBrewer: null, - - // Form data (matching manage page with snake_case) - beanForm: { - name: "", - origin: "", - roast_level: "", - process: "", - description: "", - roaster_rkey: "", - }, - grinderForm: { name: "", grinder_type: "", burr_type: "", notes: "" }, - brewerForm: { name: "", brewer_type: "", description: "" }, - // Brew form specific rating: 5, pours: [], - // Dropdown data - beans: [], - grinders: [], - brewers: [], - roasters: [], - dataLoaded: false, + // Dropdown manager instance + dropdownManager: null, + + // Entity managers for each entity type + beanManager: null, + grinderManager: null, + brewerManager: null, async init() { + // Initialize dropdown manager + this.dropdownManager = window.createDropdownManager(); + + // Initialize entity managers + this.initEntityManagers(); + // Load existing pours if editing // $el is now the parent div, so find the form element const formEl = this.$el.querySelector("form"); @@ -51,318 +38,182 @@ function brewForm() { } // Populate dropdowns from cache using stale-while-revalidate pattern - await this.loadDropdownData(); + await this.dropdownManager.loadDropdownData(); + this.dropdownManager.populateDropdowns(); }, - async loadDropdownData(forceRefresh = false) { - if (!window.ArabicaCache) { - console.warn("ArabicaCache not available"); - return; - } - - // If forcing refresh, always get fresh data - if (forceRefresh) { - try { - const freshData = await window.ArabicaCache.refreshCache(true); - if (freshData) { - this.applyData(freshData); + initEntityManagers() { + // Bean entity manager + this.beanManager = window.createEntityManager({ + entityType: "bean", + apiEndpoint: "/api/beans", + defaultFormData: { + name: "", + origin: "", + roast_level: "", + process: "", + description: "", + roaster_rkey: "", + }, + validate: (data) => { + if (!data.name || !data.origin) { + return "Bean name and origin are required"; } - } catch (e) { - console.error("Failed to refresh dropdown data:", e); - } - return; - } + return null; + }, + onSuccess: async (newBean) => { + // Refresh dropdown data and repopulate + await this.dropdownManager.invalidateAndRefresh(); + + // Select the new bean + const beanSelect = document.querySelector('form select[name="bean_rkey"]'); + if (beanSelect && newBean.rkey) { + beanSelect.value = newBean.rkey; + } + }, + }); - // First, try to immediately populate from cached data (sync) - // This prevents flickering by showing data instantly - const cachedData = window.ArabicaCache.getCachedData(); - if (cachedData) { - this.applyData(cachedData); - } + // Grinder entity manager + this.grinderManager = window.createEntityManager({ + entityType: "grinder", + apiEndpoint: "/api/grinders", + defaultFormData: { + name: "", + grinder_type: "", + burr_type: "", + notes: "", + }, + validate: (data) => { + if (!data.name) { + return "Grinder name is required"; + } + return null; + }, + onSuccess: async (newGrinder) => { + // Refresh dropdown data and repopulate + await this.dropdownManager.invalidateAndRefresh(); + + // Select the new grinder + const grinderSelect = document.querySelector('form select[name="grinder_rkey"]'); + if (grinderSelect && newGrinder.rkey) { + grinderSelect.value = newGrinder.rkey; + } + }, + }); - // Then refresh in background if cache is stale - if (!window.ArabicaCache.isCacheValid()) { - try { - const freshData = await window.ArabicaCache.refreshCache(); - if (freshData) { - this.applyData(freshData); + // Brewer entity manager + this.brewerManager = window.createEntityManager({ + entityType: "brewer", + apiEndpoint: "/api/brewers", + defaultFormData: { + name: "", + brewer_type: "", + description: "", + }, + validate: (data) => { + if (!data.name) { + return "Brewer name is required"; } - } catch (e) { - console.error("Failed to refresh dropdown data:", e); - // We already have cached data displayed, so this is non-fatal - } - } + return null; + }, + onSuccess: async (newBrewer) => { + // Refresh dropdown data and repopulate + await this.dropdownManager.invalidateAndRefresh(); + + // Select the new brewer + const brewerSelect = document.querySelector('form select[name="brewer_rkey"]'); + if (brewerSelect && newBrewer.rkey) { + brewerSelect.value = newBrewer.rkey; + } + }, + }); }, - applyData(data) { - this.beans = data.beans || []; - this.grinders = data.grinders || []; - this.brewers = data.brewers || []; - this.roasters = data.roasters || []; - this.dataLoaded = true; - - // Populate the select elements - this.populateDropdowns(); + // Pours management (brew-specific logic) + addPour() { + this.pours.push({ water: "", time: "" }); }, - populateDropdowns() { - // Get the current selected values (from server-rendered form when editing) - // Use document.querySelector to ensure we find the form selects, not modal selects - const beanSelect = document.querySelector('form select[name="bean_rkey"]'); - const grinderSelect = document.querySelector('form select[name="grinder_rkey"]'); - const brewerSelect = document.querySelector('form select[name="brewer_rkey"]'); - - const selectedBean = beanSelect?.value || ""; - const selectedGrinder = grinderSelect?.value || ""; - const selectedBrewer = brewerSelect?.value || ""; - - // Populate beans - using DOM methods to prevent XSS - if (beanSelect && this.beans.length > 0) { - // Clear existing options - beanSelect.innerHTML = ""; - - // Add placeholder - const placeholderOption = document.createElement("option"); - placeholderOption.value = ""; - placeholderOption.textContent = "Select a bean..."; - beanSelect.appendChild(placeholderOption); - - // Add bean options - this.beans.forEach((bean) => { - const option = document.createElement("option"); - option.value = bean.rkey || bean.RKey; - const roasterName = bean.Roaster?.Name || bean.roaster?.name || ""; - const roasterSuffix = roasterName ? ` - ${roasterName}` : ""; - // Using textContent ensures all user input is safely escaped - option.textContent = `${bean.Name || bean.name} (${bean.Origin || bean.origin} - ${bean.RoastLevel || bean.roast_level})${roasterSuffix}`; - option.className = "truncate"; - if ((bean.rkey || bean.RKey) === selectedBean) { - option.selected = true; - } - beanSelect.appendChild(option); - }); - } + removePour(index) { + this.pours.splice(index, 1); + }, - // Populate grinders - using DOM methods to prevent XSS - if (grinderSelect && this.grinders.length > 0) { - // Clear existing options - grinderSelect.innerHTML = ""; + // Expose entity manager state to Alpine + get showBeanForm() { + return this.beanManager?.showForm || false; + }, + set showBeanForm(value) { + if (this.beanManager) this.beanManager.showForm = value; + }, - // Add placeholder - const placeholderOption = document.createElement("option"); - placeholderOption.value = ""; - placeholderOption.textContent = "Select a grinder..."; - grinderSelect.appendChild(placeholderOption); + get showGrinderForm() { + return this.grinderManager?.showForm || false; + }, + set showGrinderForm(value) { + if (this.grinderManager) this.grinderManager.showForm = value; + }, - // Add grinder options - this.grinders.forEach((grinder) => { - const option = document.createElement("option"); - option.value = grinder.rkey || grinder.RKey; - // Using textContent ensures all user input is safely escaped - option.textContent = grinder.Name || grinder.name; - option.className = "truncate"; - if ((grinder.rkey || grinder.RKey) === selectedGrinder) { - option.selected = true; - } - grinderSelect.appendChild(option); - }); - } + get showBrewerForm() { + return this.brewerManager?.showForm || false; + }, + set showBrewerForm(value) { + if (this.brewerManager) this.brewerManager.showForm = value; + }, - // Populate brewers - using DOM methods to prevent XSS - if (brewerSelect && this.brewers.length > 0) { - // Clear existing options - brewerSelect.innerHTML = ""; + // Expose entity manager form data to Alpine + get beanForm() { + return this.beanManager?.formData || {}; + }, + set beanForm(value) { + if (this.beanManager) this.beanManager.formData = value; + }, - // Add placeholder - const placeholderOption = document.createElement("option"); - placeholderOption.value = ""; - placeholderOption.textContent = "Select brew method..."; - brewerSelect.appendChild(placeholderOption); + get grinderForm() { + return this.grinderManager?.formData || {}; + }, + set grinderForm(value) { + if (this.grinderManager) this.grinderManager.formData = value; + }, - // Add brewer options - this.brewers.forEach((brewer) => { - const option = document.createElement("option"); - option.value = brewer.rkey || brewer.RKey; - // Using textContent ensures all user input is safely escaped - option.textContent = brewer.Name || brewer.name; - option.className = "truncate"; - if ((brewer.rkey || brewer.RKey) === selectedBrewer) { - option.selected = true; - } - brewerSelect.appendChild(option); - }); - } + get brewerForm() { + return this.brewerManager?.formData || {}; + }, + set brewerForm(value) { + if (this.brewerManager) this.brewerManager.formData = value; + }, - // Populate roasters in new bean modal - using DOM methods to prevent XSS - const roasterSelect = document.querySelector('select[name="roaster_rkey_modal"]'); - if (roasterSelect && this.roasters.length > 0) { - // Clear existing options - roasterSelect.innerHTML = ""; + // Expose dropdown data to Alpine + get beans() { + return this.dropdownManager?.beans || []; + }, - // Add placeholder - const placeholderOption = document.createElement("option"); - placeholderOption.value = ""; - placeholderOption.textContent = "No roaster"; - roasterSelect.appendChild(placeholderOption); + get grinders() { + return this.dropdownManager?.grinders || []; + }, - // Add roaster options - this.roasters.forEach((roaster) => { - const option = document.createElement("option"); - option.value = roaster.rkey || roaster.RKey; - // Using textContent ensures all user input is safely escaped - option.textContent = roaster.Name || roaster.name; - roasterSelect.appendChild(option); - }); - } + get brewers() { + return this.dropdownManager?.brewers || []; }, - addPour() { - this.pours.push({ water: "", time: "" }); + get roasters() { + return this.dropdownManager?.roasters || []; }, - removePour(index) { - this.pours.splice(index, 1); + get dataLoaded() { + return this.dropdownManager?.dataLoaded || false; }, + // Delegate save methods to entity managers async saveBean() { - if (!this.beanForm.name || !this.beanForm.origin) { - alert("Bean name and origin are required"); - return; - } - - const response = await fetch("/api/beans", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(this.beanForm), - }); - - if (response.ok) { - const newBean = await response.json(); - - // Invalidate cache and refresh data in one call - let freshData = null; - if (window.ArabicaCache) { - freshData = await window.ArabicaCache.invalidateAndRefresh(); - } - - // Apply the fresh data to update dropdowns - if (freshData) { - this.applyData(freshData); - } - - // Select the new bean - const beanSelect = document.querySelector('form select[name="bean_rkey"]'); - if (beanSelect && newBean.rkey) { - beanSelect.value = newBean.rkey; - } - - // Close modal and reset form - this.showBeanForm = false; - this.beanForm = { - name: "", - origin: "", - roast_level: "", - process: "", - description: "", - roaster_rkey: "", - }; - } else { - const errorText = await response.text(); - alert("Failed to add bean: " + errorText); - } + await this.beanManager.save(); }, async saveGrinder() { - if (!this.grinderForm.name) { - alert("Grinder name is required"); - return; - } - - const response = await fetch("/api/grinders", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(this.grinderForm), - }); - - if (response.ok) { - const newGrinder = await response.json(); - - // Invalidate cache and refresh data in one call - let freshData = null; - if (window.ArabicaCache) { - freshData = await window.ArabicaCache.invalidateAndRefresh(); - } - - // Apply the fresh data to update dropdowns - if (freshData) { - this.applyData(freshData); - } - - // Select the new grinder - const grinderSelect = document.querySelector('form select[name="grinder_rkey"]'); - if (grinderSelect && newGrinder.rkey) { - grinderSelect.value = newGrinder.rkey; - } - - // Close modal and reset form - this.showGrinderForm = false; - this.grinderForm = { - name: "", - grinder_type: "", - burr_type: "", - notes: "", - }; - } else { - const errorText = await response.text(); - alert("Failed to add grinder: " + errorText); - } + await this.grinderManager.save(); }, async saveBrewer() { - if (!this.brewerForm.name) { - alert("Brewer name is required"); - return; - } - - const response = await fetch("/api/brewers", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(this.brewerForm), - }); - - if (response.ok) { - const newBrewer = await response.json(); - - // Invalidate cache and refresh data in one call - let freshData = null; - if (window.ArabicaCache) { - freshData = await window.ArabicaCache.invalidateAndRefresh(); - } - - // Apply the fresh data to update dropdowns - if (freshData) { - this.applyData(freshData); - } - - // Select the new brewer - const brewerSelect = document.querySelector('form select[name="brewer_rkey"]'); - if (brewerSelect && newBrewer.rkey) { - brewerSelect.value = newBrewer.rkey; - } - - // Close modal and reset form - this.showBrewerForm = false; - this.brewerForm = { name: "", brewer_type: "", description: "" }; - } else { - const errorText = await response.text(); - alert("Failed to add brewer: " + errorText); - } + await this.brewerManager.save(); }, }; } diff --git a/web/static/js/dropdown-manager.js b/web/static/js/dropdown-manager.js new file mode 100644 index 0000000..501f19f --- /dev/null +++ b/web/static/js/dropdown-manager.js @@ -0,0 +1,239 @@ +/** + * Dropdown Manager Module + * Handles dropdown population with stale-while-revalidate caching pattern + * Eliminates duplication of dropdown logic in brew-form.js + */ + +/** + * Creates a dropdown manager instance + * @returns {Object} Dropdown manager with methods for loading and populating data + */ +function createDropdownManager() { + return { + // Cached data + beans: [], + grinders: [], + brewers: [], + roasters: [], + dataLoaded: false, + + /** + * Loads dropdown data using stale-while-revalidate pattern + * @param {boolean} forceRefresh - Force a fresh fetch from server + */ + async loadDropdownData(forceRefresh = false) { + if (!window.ArabicaCache) { + console.warn("ArabicaCache not available"); + return; + } + + // If forcing refresh, always get fresh data + if (forceRefresh) { + try { + const freshData = await window.ArabicaCache.refreshCache(true); + if (freshData) { + this.applyData(freshData); + } + } catch (e) { + console.error("Failed to refresh dropdown data:", e); + } + return; + } + + // First, try to immediately populate from cached data (sync) + // This prevents flickering by showing data instantly + const cachedData = window.ArabicaCache.getCachedData(); + if (cachedData) { + this.applyData(cachedData); + } + + // Then refresh in background if cache is stale + if (!window.ArabicaCache.isCacheValid()) { + try { + const freshData = await window.ArabicaCache.refreshCache(); + if (freshData) { + this.applyData(freshData); + } + } catch (e) { + console.error("Failed to refresh dropdown data:", e); + // We already have cached data displayed, so this is non-fatal + } + } + }, + + /** + * Applies loaded data to local state + * @param {Object} data - Data object with beans, grinders, brewers, roasters + */ + applyData(data) { + this.beans = data.beans || []; + this.grinders = data.grinders || []; + this.brewers = data.brewers || []; + this.roasters = data.roasters || []; + this.dataLoaded = true; + }, + + /** + * Populates all dropdowns with current data + * Preserves currently selected values + */ + populateDropdowns() { + this.populateBeans(); + this.populateGrinders(); + this.populateBrewers(); + this.populateRoasters(); + }, + + /** + * Populates bean dropdown + * @param {string} selectSelector - CSS selector for the select element (optional) + */ + populateBeans(selectSelector = 'form select[name="bean_rkey"]') { + const beanSelect = document.querySelector(selectSelector); + if (!beanSelect || this.beans.length === 0) return; + + const selectedBean = beanSelect.value || ""; + + // Clear existing options + beanSelect.innerHTML = ""; + + // Add placeholder + const placeholderOption = document.createElement("option"); + placeholderOption.value = ""; + placeholderOption.textContent = "Select a bean..."; + beanSelect.appendChild(placeholderOption); + + // Add bean options + this.beans.forEach((bean) => { + const option = document.createElement("option"); + option.value = bean.rkey || bean.RKey; + const roasterName = bean.Roaster?.Name || bean.roaster?.name || ""; + const roasterSuffix = roasterName ? ` - ${roasterName}` : ""; + // Using textContent ensures all user input is safely escaped + option.textContent = `${bean.Name || bean.name} (${bean.Origin || bean.origin} - ${bean.RoastLevel || bean.roast_level})${roasterSuffix}`; + option.className = "truncate"; + if ((bean.rkey || bean.RKey) === selectedBean) { + option.selected = true; + } + beanSelect.appendChild(option); + }); + }, + + /** + * Populates grinder dropdown + * @param {string} selectSelector - CSS selector for the select element (optional) + */ + populateGrinders(selectSelector = 'form select[name="grinder_rkey"]') { + const grinderSelect = document.querySelector(selectSelector); + if (!grinderSelect || this.grinders.length === 0) return; + + const selectedGrinder = grinderSelect.value || ""; + + // Clear existing options + grinderSelect.innerHTML = ""; + + // Add placeholder + const placeholderOption = document.createElement("option"); + placeholderOption.value = ""; + placeholderOption.textContent = "Select a grinder..."; + grinderSelect.appendChild(placeholderOption); + + // Add grinder options + this.grinders.forEach((grinder) => { + const option = document.createElement("option"); + option.value = grinder.rkey || grinder.RKey; + // Using textContent ensures all user input is safely escaped + option.textContent = grinder.Name || grinder.name; + option.className = "truncate"; + if ((grinder.rkey || grinder.RKey) === selectedGrinder) { + option.selected = true; + } + grinderSelect.appendChild(option); + }); + }, + + /** + * Populates brewer dropdown + * @param {string} selectSelector - CSS selector for the select element (optional) + */ + populateBrewers(selectSelector = 'form select[name="brewer_rkey"]') { + const brewerSelect = document.querySelector(selectSelector); + if (!brewerSelect || this.brewers.length === 0) return; + + const selectedBrewer = brewerSelect.value || ""; + + // Clear existing options + brewerSelect.innerHTML = ""; + + // Add placeholder + const placeholderOption = document.createElement("option"); + placeholderOption.value = ""; + placeholderOption.textContent = "Select brew method..."; + brewerSelect.appendChild(placeholderOption); + + // Add brewer options + this.brewers.forEach((brewer) => { + const option = document.createElement("option"); + option.value = brewer.rkey || brewer.RKey; + // Using textContent ensures all user input is safely escaped + option.textContent = brewer.Name || brewer.name; + option.className = "truncate"; + if ((brewer.rkey || brewer.RKey) === selectedBrewer) { + option.selected = true; + } + brewerSelect.appendChild(option); + }); + }, + + /** + * Populates roaster dropdown (used in new bean modal) + * @param {string} selectSelector - CSS selector for the select element (optional) + */ + populateRoasters(selectSelector = 'select[name="roaster_rkey_modal"]') { + const roasterSelect = document.querySelector(selectSelector); + if (!roasterSelect || this.roasters.length === 0) return; + + const selectedRoaster = roasterSelect.value || ""; + + // Clear existing options + roasterSelect.innerHTML = ""; + + // Add placeholder + const placeholderOption = document.createElement("option"); + placeholderOption.value = ""; + placeholderOption.textContent = "No roaster"; + roasterSelect.appendChild(placeholderOption); + + // Add roaster options + this.roasters.forEach((roaster) => { + const option = document.createElement("option"); + option.value = roaster.rkey || roaster.RKey; + // Using textContent ensures all user input is safely escaped + option.textContent = roaster.Name || roaster.name; + if ((roaster.rkey || roaster.RKey) === selectedRoaster) { + option.selected = true; + } + roasterSelect.appendChild(option); + }); + }, + + /** + * Invalidates cache and refreshes dropdowns + * @returns {Promise} Fresh data from cache + */ + async invalidateAndRefresh() { + if (window.ArabicaCache) { + const freshData = await window.ArabicaCache.invalidateAndRefresh(); + if (freshData) { + this.applyData(freshData); + this.populateDropdowns(); + } + return freshData; + } + return null; + }, + }; +} + +// Export for use in other modules +window.createDropdownManager = createDropdownManager; diff --git a/web/static/js/entity-manager.js b/web/static/js/entity-manager.js new file mode 100644 index 0000000..fdfb967 --- /dev/null +++ b/web/static/js/entity-manager.js @@ -0,0 +1,159 @@ +/** + * Entity Manager Module + * Provides reusable CRUD operations for entity management + * Eliminates duplication between brew-form.js and manage-page.js + */ + +/** + * Creates an entity manager for a specific entity type + * @param {Object} config - Configuration object + * @param {string} config.entityType - Entity type name (e.g., 'bean', 'grinder', 'brewer', 'roaster') + * @param {string} config.apiEndpoint - API endpoint (e.g., '/api/beans') + * @param {Object} config.defaultFormData - Default form data structure + * @param {Function} config.validate - Validation function that returns error message or null + * @param {Function} config.onSuccess - Callback after successful save/delete (optional) + * @param {boolean} config.reloadOnSuccess - Whether to reload page on success (default: false) + * @returns {Object} Entity manager with CRUD methods + */ +function createEntityManager(config) { + const { + entityType, + apiEndpoint, + defaultFormData, + validate, + onSuccess, + reloadOnSuccess = false, + } = config; + + return { + // Modal state + showForm: false, + editingId: null, + + // Form data (will be initialized with defaultFormData) + formData: { ...defaultFormData }, + + /** + * Opens the form for creating a new entity + */ + openNew() { + this.editingId = null; + this.formData = { ...defaultFormData }; + this.showForm = true; + }, + + /** + * Opens the form for editing an existing entity + * @param {string} rkey - Record key + * @param {Object} data - Entity data to populate form + */ + openEdit(rkey, data) { + this.editingId = rkey; + this.formData = { ...data }; + this.showForm = true; + }, + + /** + * Saves the entity (create or update) + */ + async save() { + // Validate form data + const error = validate(this.formData); + if (error) { + alert(error); + return; + } + + // Determine URL and method + const url = this.editingId + ? `${apiEndpoint}/${this.editingId}` + : apiEndpoint; + const method = this.editingId ? "PUT" : "POST"; + + try { + const response = await fetch(url, { + method, + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(this.formData), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText); + } + + const result = await response.json(); + + // Invalidate cache + if (window.ArabicaCache) { + window.ArabicaCache.invalidateCache(); + } + + // Call success callback if provided + if (onSuccess) { + await onSuccess(result, this.editingId); + } + + // Close modal and reset form + this.showForm = false; + this.formData = { ...defaultFormData }; + this.editingId = null; + + // Reload page if configured to do so + if (reloadOnSuccess) { + window.location.reload(); + } + } catch (error) { + const action = this.editingId ? "update" : "add"; + alert(`Failed to ${action} ${entityType}: ${error.message}`); + } + }, + + /** + * Deletes an entity + * @param {string} rkey - Record key to delete + */ + async delete(rkey) { + if (!confirm(`Are you sure you want to delete this ${entityType}?`)) { + return; + } + + try { + const response = await fetch(`${apiEndpoint}/${rkey}`, { + method: "DELETE", + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText); + } + + // Invalidate cache + if (window.ArabicaCache) { + window.ArabicaCache.invalidateCache(); + } + + // Reload page if configured to do so + if (reloadOnSuccess) { + window.location.reload(); + } + } catch (error) { + alert(`Failed to delete ${entityType}: ${error.message}`); + } + }, + + /** + * Closes the form modal without saving + */ + closeForm() { + this.showForm = false; + this.formData = { ...defaultFormData }; + this.editingId = null; + }, + }; +} + +// Export for use in other modules +window.createEntityManager = createEntityManager; diff --git a/web/static/js/manage-page.js b/web/static/js/manage-page.js index e80c644..ecffc2f 100644 --- a/web/static/js/manage-page.js +++ b/web/static/js/manage-page.js @@ -1,32 +1,21 @@ /** * Alpine.js component for the manage page * Handles CRUD operations for beans, roasters, grinders, and brewers + * Uses shared entity-manager module to eliminate duplication */ function managePage() { return { tab: localStorage.getItem("manageTab") || "beans", activeTab: localStorage.getItem("profileTab") || "brews", - showBeanForm: false, - showRoasterForm: false, - showGrinderForm: false, - showBrewerForm: false, - editingBean: null, - editingRoaster: null, - editingGrinder: null, - editingBrewer: null, - beanForm: { - name: "", - origin: "", - roast_level: "", - process: "", - description: "", - roaster_rkey: "", - }, - roasterForm: { name: "", location: "", website: "" }, - grinderForm: { name: "", grinder_type: "", burr_type: "", notes: "" }, - brewerForm: { name: "", brewer_type: "", description: "" }, + + // Entity managers for each entity type + beanManager: null, + roasterManager: null, + grinderManager: null, + brewerManager: null, init() { + // Watch tab changes and persist to localStorage this.$watch("tab", (value) => { localStorage.setItem("manageTab", value); }); @@ -39,192 +28,169 @@ function managePage() { if (window.ArabicaCache) { window.ArabicaCache.init(); } - }, - editBean( - rkey, - name, - origin, - roast_level, - process, - description, - roaster_rkey, - ) { - this.editingBean = rkey; - this.beanForm = { - name, - origin, - roast_level, - process, - description, - roaster_rkey: roaster_rkey || "", - }; - this.showBeanForm = true; + // Initialize entity managers + this.initEntityManagers(); }, - async saveBean() { - if (!this.beanForm.name || !this.beanForm.origin) { - alert("Name and Origin are required"); - return; - } - - const url = this.editingBean - ? `/api/beans/${this.editingBean}` - : "/api/beans"; - const method = this.editingBean ? "PUT" : "POST"; - - const response = await fetch(url, { - method, - headers: { - "Content-Type": "application/json", + initEntityManagers() { + // Bean entity manager + this.beanManager = window.createEntityManager({ + entityType: "bean", + apiEndpoint: "/api/beans", + defaultFormData: { + name: "", + origin: "", + roast_level: "", + process: "", + description: "", + roaster_rkey: "", + }, + validate: (data) => { + if (!data.name || !data.origin) { + return "Name and Origin are required"; + } + return null; }, - body: JSON.stringify(this.beanForm), + reloadOnSuccess: true, }); - if (response.ok) { - // Invalidate cache and reload - if (window.ArabicaCache) { - window.ArabicaCache.invalidateCache(); - } - window.location.reload(); - } else { - const errorText = await response.text(); - alert("Failed to save bean: " + errorText); - } - }, + // Roaster entity manager + this.roasterManager = window.createEntityManager({ + entityType: "roaster", + apiEndpoint: "/api/roasters", + defaultFormData: { + name: "", + location: "", + website: "", + }, + validate: (data) => { + if (!data.name) { + return "Name is required"; + } + return null; + }, + reloadOnSuccess: true, + }); - async deleteBean(rkey) { - if (!confirm("Are you sure you want to delete this bean?")) return; + // Grinder entity manager + this.grinderManager = window.createEntityManager({ + entityType: "grinder", + apiEndpoint: "/api/grinders", + defaultFormData: { + name: "", + grinder_type: "", + burr_type: "", + notes: "", + }, + validate: (data) => { + if (!data.name || !data.grinder_type) { + return "Name and Grinder Type are required"; + } + return null; + }, + reloadOnSuccess: true, + }); - const response = await fetch(`/api/beans/${rkey}`, { - method: "DELETE", + // Brewer entity manager + this.brewerManager = window.createEntityManager({ + entityType: "brewer", + apiEndpoint: "/api/brewers", + defaultFormData: { + name: "", + brewer_type: "", + description: "", + }, + validate: (data) => { + if (!data.name) { + return "Name is required"; + } + return null; + }, + reloadOnSuccess: true, }); - if (response.ok) { - // Invalidate cache and reload - if (window.ArabicaCache) { - window.ArabicaCache.invalidateCache(); - } - window.location.reload(); - } else { - const errorText = await response.text(); - alert("Failed to delete bean: " + errorText); - } }, - editRoaster(rkey, name, location, website) { - this.editingRoaster = rkey; - this.roasterForm = { name, location, website }; - this.showRoasterForm = true; + // Expose entity manager modal state to Alpine + get showBeanForm() { + return this.beanManager?.showForm || false; + }, + set showBeanForm(value) { + if (this.beanManager) this.beanManager.showForm = value; }, - async saveRoaster() { - if (!this.roasterForm.name) { - alert("Name is required"); - return; - } - - const url = this.editingRoaster - ? `/api/roasters/${this.editingRoaster}` - : "/api/roasters"; - const method = this.editingRoaster ? "PUT" : "POST"; - - const response = await fetch(url, { - method, - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(this.roasterForm), - }); + get showRoasterForm() { + return this.roasterManager?.showForm || false; + }, + set showRoasterForm(value) { + if (this.roasterManager) this.roasterManager.showForm = value; + }, - if (response.ok) { - // Invalidate cache and reload - if (window.ArabicaCache) { - window.ArabicaCache.invalidateCache(); - } - window.location.reload(); - } else { - const errorText = await response.text(); - alert("Failed to save roaster: " + errorText); - } + get showGrinderForm() { + return this.grinderManager?.showForm || false; + }, + set showGrinderForm(value) { + if (this.grinderManager) this.grinderManager.showForm = value; }, - async deleteRoaster(rkey) { - if (!confirm("Are you sure you want to delete this roaster?")) return; + get showBrewerForm() { + return this.brewerManager?.showForm || false; + }, + set showBrewerForm(value) { + if (this.brewerManager) this.brewerManager.showForm = value; + }, - const response = await fetch(`/api/roasters/${rkey}`, { - method: "DELETE", - }); - if (response.ok) { - // Invalidate cache and reload - if (window.ArabicaCache) { - window.ArabicaCache.invalidateCache(); - } - window.location.reload(); - } else { - const errorText = await response.text(); - alert("Failed to delete roaster: " + errorText); - } + // Expose entity manager form data to Alpine + get beanForm() { + return this.beanManager?.formData || {}; + }, + set beanForm(value) { + if (this.beanManager) this.beanManager.formData = value; }, - editGrinder(rkey, name, grinder_type, burr_type, notes) { - this.editingGrinder = rkey; - this.grinderForm = { name, grinder_type, burr_type, notes }; - this.showGrinderForm = true; + get roasterForm() { + return this.roasterManager?.formData || {}; + }, + set roasterForm(value) { + if (this.roasterManager) this.roasterManager.formData = value; }, - async saveGrinder() { - if (!this.grinderForm.name || !this.grinderForm.grinder_type) { - alert("Name and Grinder Type are required"); - return; - } + get grinderForm() { + return this.grinderManager?.formData || {}; + }, + set grinderForm(value) { + if (this.grinderManager) this.grinderManager.formData = value; + }, - const url = this.editingGrinder - ? `/api/grinders/${this.editingGrinder}` - : "/api/grinders"; - const method = this.editingGrinder ? "PUT" : "POST"; + get brewerForm() { + return this.brewerManager?.formData || {}; + }, + set brewerForm(value) { + if (this.brewerManager) this.brewerManager.formData = value; + }, - const response = await fetch(url, { - method, - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(this.grinderForm), + // Edit methods - populate form and open modal + editBean(rkey, name, origin, roast_level, process, description, roaster_rkey) { + this.beanManager.openEdit(rkey, { + name, + origin, + roast_level, + process, + description, + roaster_rkey: roaster_rkey || "", }); - - if (response.ok) { - // Invalidate cache and reload - if (window.ArabicaCache) { - window.ArabicaCache.invalidateCache(); - } - window.location.reload(); - } else { - const errorText = await response.text(); - alert("Failed to save grinder: " + errorText); - } }, - async deleteGrinder(rkey) { - if (!confirm("Are you sure you want to delete this grinder?")) return; + editRoaster(rkey, name, location, website) { + this.roasterManager.openEdit(rkey, { name, location, website }); + }, - const response = await fetch(`/api/grinders/${rkey}`, { - method: "DELETE", - }); - if (response.ok) { - // Invalidate cache and reload - if (window.ArabicaCache) { - window.ArabicaCache.invalidateCache(); - } - window.location.reload(); - } else { - const errorText = await response.text(); - alert("Failed to delete grinder: " + errorText); - } + editGrinder(rkey, name, grinder_type, burr_type, notes) { + this.grinderManager.openEdit(rkey, { name, grinder_type, burr_type, notes }); }, editBrewer(rkey, name, brewer_type, description) { - this.editingBrewer = rkey; - this.brewerForm = { name, brewer_type, description }; - this.showBrewerForm = true; + this.brewerManager.openEdit(rkey, { name, brewer_type, description }); }, editBrewerFromRow(row) { @@ -235,53 +201,38 @@ function managePage() { this.editBrewer(rkey, name, brewer_type, description); }, + // Delegate save methods to entity managers + async saveBean() { + await this.beanManager.save(); + }, + + async saveRoaster() { + await this.roasterManager.save(); + }, + + async saveGrinder() { + await this.grinderManager.save(); + }, + async saveBrewer() { - if (!this.brewerForm.name) { - alert("Name is required"); - return; - } + await this.brewerManager.save(); + }, - const url = this.editingBrewer - ? `/api/brewers/${this.editingBrewer}` - : "/api/brewers"; - const method = this.editingBrewer ? "PUT" : "POST"; + // Delegate delete methods to entity managers + async deleteBean(rkey) { + await this.beanManager.delete(rkey); + }, - const response = await fetch(url, { - method, - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(this.brewerForm), - }); + async deleteRoaster(rkey) { + await this.roasterManager.delete(rkey); + }, - if (response.ok) { - // Invalidate cache and reload - if (window.ArabicaCache) { - window.ArabicaCache.invalidateCache(); - } - window.location.reload(); - } else { - const errorText = await response.text(); - alert("Failed to save brewer: " + errorText); - } + async deleteGrinder(rkey) { + await this.grinderManager.delete(rkey); }, async deleteBrewer(rkey) { - if (!confirm("Are you sure you want to delete this brewer?")) return; - - const response = await fetch(`/api/brewers/${rkey}`, { - method: "DELETE", - }); - if (response.ok) { - // Invalidate cache and reload - if (window.ArabicaCache) { - window.ArabicaCache.invalidateCache(); - } - window.location.reload(); - } else { - const errorText = await response.text(); - alert("Failed to delete brewer: " + errorText); - } + await this.brewerManager.delete(rkey); }, }; }