diff --git a/CLAUDE.md b/CLAUDE.md index 3a77a9d..98d006c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,9 @@ Coffee brew tracking application using AT Protocol for decentralized storage. ## Work Management -This project uses **cells** for task tracking and coordination. Cells are atomic, dependency-aware units of work designed for coordination between humans and AI agents. +This project uses **cells** for task tracking and coordination. Cells are +atomic, dependency-aware units of work designed for coordination between humans +and AI agents. **For usage instructions, see:** `.cells/AGENTS.md` @@ -14,31 +16,52 @@ Quick reference for AI agents: - `./cells list --status open` - Show available work - `./cells show ` - View cell details -**Note:** Do NOT use `./cells run` - this spawns a new agent session and should only be used by humans. AI agents should inspect cells using the commands above and perform work directly. +**Note:** Do NOT use `./cells run` - this spawns a new agent session and should +only be used by humans. AI agents should inspect cells using the commands above +and perform work directly. -All work items are tracked as cells. When starting new work, check for existing cells first. +All work items are tracked as cells. When starting new work, check for existing +cells first. ## Workflow Rules -Do NOT spend more than 2-3 minutes exploring/reading files before beginning implementation. If the task is clear, start writing code immediately. Ask clarifying questions rather than endlessly reading the codebase. When given a specific implementation task, produce code changes in the same session. +Do NOT spend more than 2-3 minutes exploring/reading files before beginning +implementation. If the task is clear, start writing code immediately. Ask +clarifying questions rather than endlessly reading the codebase. When given a +specific implementation task, produce code changes in the same session. ## Dependencies -When implementing features, prefer standard library solutions over external dependencies. Only add a third-party dependency if the standard library genuinely cannot handle the requirement. For Go: check stdlib first (e.g., os.Stdout for TTY detection). For JS/TS: check built-in APIs before npm packages. +When implementing features, prefer standard library solutions over external +dependencies. Only add a third-party dependency if the standard library +genuinely cannot handle the requirement. For Go: check stdlib first (e.g., +os.Stdout for TTY detection). For JS/TS: check built-in APIs before npm +packages. ## Task Agents -When spawning task agents, set a hard limit of 3 agents maximum. Each agent must have a clearly scoped deliverable and file output path. Do not poll agents in a loopβ€”instead, give each agent its full instructions upfront and collect results at the end. If agents aren't producing results within 5 minutes, fall back to doing the work directly. +When spawning task agents, set a hard limit of 3 agents maximum. Each agent must +have a clearly scoped deliverable and file output path. Do not poll agents in a +loopβ€”instead, give each agent its full instructions upfront and collect results +at the end. If agents aren't producing results within 5 minutes, fall back to +doing the work directly. ## Testing & Verification -For Go projects: always run `go vet ./...` and `go build ./...` after making changes. For JavaScript/CSS projects: verify template field names match backend struct fields before considering a task complete. Always test form submissions to verify content-type handling (JSON vs form-encoded). +For Go projects: always run `go vet ./...` and `go build ./...` after making +changes. For JavaScript/CSS projects: verify template field names match backend +struct fields before considering a task complete. Always test form submissions +to verify content-type handling (JSON vs form-encoded). ### Using Go Tooling Effectively -- To see source files from a dependency, or to answer questions about a dependency, run `go mod download -json MODULE` and use the returned `Dir` path to read the files. -- Use `go doc foo.Bar` or `go doc -all foo` to read documentation for packages, types, functions, etc. -- Use `go run .` or `go run ./cmd/foo` instead of `go build` to run programs, to avoid leaving behind build artifacts. +- To see source files from a dependency, or to answer questions about a + dependency, run `go mod download -json MODULE` and use the returned `Dir` path + to read the files. +- Use `go doc foo.Bar` or `go doc -all foo` to read documentation for packages, + types, functions, etc. +- Use `go run .` or `go run ./cmd/foo` instead of `go build` to run programs, to + avoid leaving behind build artifacts. ## Tech Stack @@ -130,7 +153,8 @@ User data stored in their Personal Data Server (PDS), not locally. The app: **Record keys:** TID format (timestamp-based identifiers) -**References:** Records reference each other via AT-URIs (`at://did/collection/rkey`) +**References:** Records reference each other via AT-URIs +(`at://did/collection/rkey`) ### Store Interface @@ -163,20 +187,25 @@ User records are backfilled from their PDS once per DID: - **On startup**: Backfills registered users + known-dids file - **On first login**: Backfills the user's historical records -- **Deduplication**: Tracks backfilled DIDs in `BucketBackfilled` to prevent redundant fetches +- **Deduplication**: Tracks backfilled DIDs in `BucketBackfilled` to prevent + redundant fetches - **Idempotent**: Safe to call multiple times (checks backfill status first) -This prevents excessive PDS requests while ensuring new users' historical data is indexed. +This prevents excessive PDS requests while ensuring new users' historical data +is indexed. ## Templ Architecture -The application uses [Templ](https://templ.guide/) for type-safe, component-based HTML templating. Templ generates Go code at compile time, providing full type safety and IDE support. +The application uses [Templ](https://templ.guide/) for type-safe, +component-based HTML templating. Templ generates Go code at compile time, +providing full type safety and IDE support. ### Component Structure Components are organized into two categories: -**Pages** (`internal/web/pages/`) - Full-page components that compose the layout with content: +**Pages** (`internal/web/pages/`) - Full-page components that compose the layout +with content: - Each page component accepts `LayoutData` and page-specific props - Pattern: `PageName(layoutData *components.LayoutData, props PageProps)` @@ -185,7 +214,8 @@ Components are organized into two categories: **Components** (`internal/web/components/`) - Reusable UI building blocks: - `layout.templ` - Base HTML layout with head, body, header, footer -- `shared.templ` - Common UI elements (EmptyState, PageHeader, LoadingSkeletonTable, etc.) +- `shared.templ` - Common UI elements (EmptyState, PageHeader, + LoadingSkeletonTable, etc.) - `buttons.templ` - PrimaryButton, SecondaryButton, BackButton - `forms.templ` - TextInput, NumberInput, TextArea, Select, FormField - `modal.templ` - Modal dialogs with Alpine.js integration @@ -194,7 +224,8 @@ Components are organized into two categories: ### LayoutData Pattern -Every page shares a common `LayoutData` struct that contains authentication state and user information: +Every page shares a common `LayoutData` struct that contains authentication +state and user information: ```go type LayoutData struct { @@ -206,7 +237,8 @@ type LayoutData struct { } ``` -This data flows from handlers to the layout component, ensuring consistent header/footer rendering across all pages. +This data flows from handlers to the layout component, ensuring consistent +header/footer rendering across all pages. ### Handler Integration @@ -245,7 +277,8 @@ func (h *Handler) HandlePageName(w http.ResponseWriter, r *http.Request) { ### The Render(r.Context(), w) Pattern -All templ components implement the `templ.Component` interface, which includes a `Render(ctx context.Context, w io.Writer)` method. This method: +All templ components implement the `templ.Component` interface, which includes a +`Render(ctx context.Context, w io.Writer)` method. This method: - Takes the request context for cancellation/timeout support - Writes directly to the http.ResponseWriter @@ -424,7 +457,11 @@ templ generate --watch The generated `*_templ.go` should be regenerated whenever `.templ` files change. -Templ files must use tabs rather than spaces. **Never use spaces for indentation in `.templ` files** β€” the templ parser will error with a parse failure. This applies when writing new components, editing existing ones, and constructing multi-line template strings. A post-edit hook runs `templ fmt` automatically to catch any accidental spaces. +Templ files must use tabs rather than spaces. **Never use spaces for indentation +in `.templ` files** β€” the templ parser will error with a parse failure. This +applies when writing new components, editing existing ones, and constructing +multi-line template strings. A post-edit hook runs `templ fmt` automatically to +catch any accidental spaces. ## Command-Line Flags @@ -442,19 +479,19 @@ Templ files must use tabs rather than spaces. **Never use spaces for indentation ## Environment Variables -| Variable | Default | Description | -| --------------------------- | ------------------------------------ | -------------------------------- | -| `PORT` | 18910 | HTTP server port | -| `SERVER_PUBLIC_URL` | - | Public URL for reverse proxy | -| `ARABICA_DB_PATH` | ~/.local/share/arabica/arabica.db | BoltDB path (sessions, registry) | -| `ARABICA_FEED_INDEX_PATH` | ~/.local/share/arabica/feed-index.db | Firehose index BoltDB path | -| `ARABICA_MODERATORS_CONFIG` | - | Path to moderators JSON config | -| `ARABICA_PROFILE_CACHE_TTL` | 1h | Profile cache duration | -| `OTEL_EXPORTER_OTLP_ENDPOINT` | localhost:4318 | OTLP HTTP endpoint for traces | -| `METRICS_PORT` | 9101 | Internal metrics server port (localhost only) | -| `SECURE_COOKIES` | false | Set true for HTTPS | -| `LOG_LEVEL` | info | debug/info/warn/error | -| `LOG_FORMAT` | console | console/json | +| Variable | Default | Description | +| ----------------------------- | ------------------------------------ | --------------------------------------------- | +| `PORT` | 18910 | HTTP server port | +| `SERVER_PUBLIC_URL` | - | Public URL for reverse proxy | +| `ARABICA_DB_PATH` | ~/.local/share/arabica/arabica.db | BoltDB path (sessions, registry) | +| `ARABICA_FEED_INDEX_PATH` | ~/.local/share/arabica/feed-index.db | Firehose index BoltDB path | +| `ARABICA_MODERATORS_CONFIG` | - | Path to moderators JSON config | +| `ARABICA_PROFILE_CACHE_TTL` | 1h | Profile cache duration | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | localhost:4318 | OTLP HTTP endpoint for traces | +| `METRICS_PORT` | 9101 | Internal metrics server port (localhost only) | +| `SECURE_COOKIES` | false | Set true for HTTPS | +| `LOG_LEVEL` | info | debug/info/warn/error | +| `LOG_FORMAT` | console | console/json | ## Code Patterns @@ -558,7 +595,9 @@ func (h *Handler) HandleBrewsPartial(w http.ResponseWriter, r *http.Request) { ### Testing Conventions -**IMPORTANT:** All tests in this codebase MUST use [testify/assert](https://github.com/stretchr/testify) for assertions. Do NOT use `if` statements with `t.Error()` or `t.Errorf()`. +**IMPORTANT:** All tests in this codebase MUST use +[testify/assert](https://github.com/stretchr/testify) for assertions. Do NOT use +`if` statements with `t.Error()` or `t.Errorf()`. ```go // CORRECT: Use testify assert @@ -608,7 +647,8 @@ func TestFormatTemp(t *testing.T) { ## Future Vision: Social Features -The app currently has a basic community feed. Future plans expand social interactions leveraging AT Protocol's decentralized nature. +The app currently has a basic community feed. Future plans expand social +interactions leveraging AT Protocol's decentralized nature. ### Planned Lexicons @@ -701,22 +741,26 @@ social.arabica.alpha.share - Re-share a brew to your feed ### Key Design Decisions -1. **Strong references** - Likes/comments use `com.atproto.repo.strongRef` (URI + CID) to ensure the referenced brew hasn't changed +1. **Strong references** - Likes/comments use `com.atproto.repo.strongRef` + (URI + CID) to ensure the referenced brew hasn't changed 2. **Actor-owned data** - Your likes live in your PDS, not the brew owner's -3. **Public by default** - Social interactions are public records, readable by anyone +3. **Public by default** - Social interactions are public records, readable by + anyone 4. **Portable identity** - Users can switch PDS and keep their social graph ## Deployment Notes ### CSS Cache Busting -When making CSS/style changes, bump the version query parameter in `internal/web/components/layout.templ`: +When making CSS/style changes, bump the version query parameter in +`internal/web/components/layout.templ`: ```html ``` -Cloudflare caches static assets, so incrementing the version ensures users get the updated styles. +Cloudflare caches static assets, so incrementing the version ensures users get +the updated styles. ### Templ Code Generation @@ -730,4 +774,5 @@ templ generate nix develop -c templ generate ``` -This is automatically handled by the build process, but you may need to run it manually during development. +This is automatically handled by the build process, but you may need to run it +manually during development. diff --git a/internal/web/components/brew_list_table.templ b/internal/web/components/brew_list_table.templ index 2a3ddcd..3139758 100644 --- a/internal/web/components/brew_list_table.templ +++ b/internal/web/components/brew_list_table.templ @@ -6,14 +6,14 @@ import ( "fmt" ) -// BrewListTableProps defines props for the brew list table partial +// BrewListTableProps defines props for the brew list partial type BrewListTableProps struct { Brews []*models.Brew IsOwnProfile bool ProfileHandle string } -// BrewListTablePartial renders the brew list table (for HTMX loading) +// BrewListTablePartial renders the brew list as feed cards (for HTMX loading) templ BrewListTablePartial(props BrewListTableProps) { if len(props.Brews) == 0 { if props.IsOwnProfile { @@ -28,149 +28,41 @@ templ BrewListTablePartial(props BrewListTableProps) { }) } } else { -
- - - - - - - - - - - - - - for _, brew := range props.Brews { - @BrewListTableRow(brew, props.IsOwnProfile, props.ProfileHandle) - } - -
πŸ“… Date@IconCoffee() Bean@IconBrewer() BrewerπŸ”§ Variables@IconFileText() Notes@IconStar() Ratingβš™οΈ Actions
+
+ for _, brew := range props.Brews { + @brewListCard(brew, props.IsOwnProfile, props.ProfileHandle) + }
} } -// BrewListTableRow renders a single brew row -templ BrewListTableRow(brew *models.Brew, isOwnProfile bool, profileHandle string) { - - - - -
- +// brewListCard renders a single brew as a feed card +templ brewListCard(brew *models.Brew, isOwnProfile bool, profileHandle string) { +
+ +
+
+
- - - - if brew.Bean != nil { -
- if brew.Bean.Name != "" { - { brew.Bean.Name } - } else { - { brew.Bean.Origin } - } -
- if brew.Bean.Roaster != nil && brew.Bean.Roaster.Name != "" { -
- { brew.Bean.Roaster.Name } -
- } -
- if brew.Bean.Origin != "" { - @IconMapPin() { brew.Bean.Origin } - } - if brew.Bean.RoastLevel != "" { - @IconFlame() { brew.Bean.RoastLevel } - } - if brew.CoffeeAmount > 0 { - @IconScale() { fmt.Sprintf("%dg", brew.CoffeeAmount) } - } -
- } else { - - - } - - - - if brew.BrewerObj != nil { -
{ brew.BrewerObj.Name }
- } else if brew.Method != "" { -
{ brew.Method }
- } else { - - - } - - - -
- if brew.GrinderObj != nil { -
- Grinder: - { " " }{ brew.GrinderObj.Name } - if brew.GrindSize != "" { - ({ brew.GrindSize }) - } -
- } else if brew.GrindSize != "" { -
Grind: { brew.GrindSize }
- } - if bff.HasTemp(brew.Temperature) { -
Temp: { bff.FormatTemp(brew.Temperature) }
- } - if len(brew.Pours) > 0 { -
Pours:
- for _, pour := range brew.Pours { -
β€’ { fmt.Sprintf("%dg @ %s", pour.WaterAmount, bff.FormatTime(pour.TimeSeconds)) }
- } - } else if brew.WaterAmount > 0 { -
Water: { fmt.Sprintf("%dg", brew.WaterAmount) }
- } - if brew.TimeSeconds > 0 { -
Time: { bff.FormatTime(brew.TimeSeconds) }
+
+ if isOwnProfile { + View + Edit + + } else if profileHandle != "" { + View + } else { + View }
- - - - if brew.TastingNotes != "" { -
{ brew.TastingNotes }
- } else { - - - } - - - - if brew.Rating > 0 { - - @IconStar() - { fmt.Sprintf("%d/10", brew.Rating) } - - } else { - - - } - - - - if isOwnProfile { - View - } else if profileHandle != "" { - View - } else { - View - } - if isOwnProfile { - Edit - - } - - +
+ + @BrewContent(brew) +
} diff --git a/internal/web/components/dialog_modals.templ b/internal/web/components/dialog_modals.templ index faf214a..907f865 100644 --- a/internal/web/components/dialog_modals.templ +++ b/internal/web/components/dialog_modals.templ @@ -588,7 +588,7 @@ templ RecipeDialogModal(recipe *models.Recipe, brewers []models.Brewer) {
diff --git a/internal/web/components/entity_tables.templ b/internal/web/components/entity_tables.templ index 474fcbd..33fabea 100644 --- a/internal/web/components/entity_tables.templ +++ b/internal/web/components/entity_tables.templ @@ -40,7 +40,7 @@ templ BeanCard(bean *models.Bean, showActions bool, ownerHandle string) {
if bean.Roaster != nil && bean.Roaster.Name != "" {
- + @IconStore() { bean.Roaster.Name } @@ -65,27 +65,27 @@ templ BeanCard(bean *models.Bean, showActions bool, ownerHandle string) {
}
-
+
if bean.Origin != "" { - + @IconMapPin() { bean.Origin } } if bean.Variety != "" { - + @IconLeaf() { bean.Variety } } if bean.RoastLevel != "" { - + @IconFlame() { bean.RoastLevel } } if bean.Process != "" { - + @IconSprout() { bean.Process } @@ -98,313 +98,294 @@ templ BeanCard(bean *models.Bean, showActions bool, ownerHandle string) {
} -// RoastersTableProps defines props for the shared roasters table +// RoastersTableProps defines props for the shared roasters display type RoastersTableProps struct { Roasters []*models.Roaster ShowActions bool // Whether to show Edit/Delete actions OwnerHandle string // If set, name links to view page with this owner } -// RoastersTable renders a table of roasters with configurable actions +// RoastersTable renders a grid of roaster cards templ RoastersTable(props RoastersTableProps) { if len(props.Roasters) == 0 { @EmptyState(EmptyStateProps{Message: "No roasters yet."}) } else { -
- - - - - - - if props.ShowActions { - - } - - - - for _, roaster := range props.Roasters { - - - - - if props.ShowActions { - - } - - } - -
NameLocationWebsiteActions
- if props.OwnerHandle != "" { - { roaster.Name } - } else { - { roaster.Name } - } - - if roaster.Location != "" { - { roaster.Location } - } else { - - - } - - if roaster.Website != "" { - if props.ShowActions { - { roaster.Website } - } else { - - { templ.URL(roaster.Website) } - - } - } else { - - - } - - - -
+
+ for _, roaster := range props.Roasters { + @RoasterCard(roaster, props.ShowActions, props.OwnerHandle) + }
} } -// GrindersTableProps defines props for the shared grinders table +// RoasterCard renders a single roaster as a compact card +templ RoasterCard(roaster *models.Roaster, showActions bool, ownerHandle string) { +
+
+
+
+
+ if ownerHandle != "" { + { roaster.Name } + } else { + { roaster.Name } + } +
+
+ if showActions { +
+ + +
+ } +
+
+ if roaster.Location != "" { + + @IconMapPin() + { roaster.Location } + + } + if roaster.Website != "" { + + @IconGlobe() + if !showActions { + { roaster.Website } + } else { + { roaster.Website } + } + + } +
+
+
+} + +// GrindersTableProps defines props for the shared grinders display type GrindersTableProps struct { Grinders []*models.Grinder ShowActions bool // Whether to show Edit/Delete actions OwnerHandle string // If set, name links to view page with this owner } -// GrindersTable renders a table of grinders with configurable actions +// GrindersTable renders a grid of grinder cards templ GrindersTable(props GrindersTableProps) { if len(props.Grinders) == 0 { @EmptyState(EmptyStateProps{Message: "No grinders yet."}) } else { -
- - - - - - - - if props.ShowActions { - - } - - - - for _, grinder := range props.Grinders { - - - - - - if props.ShowActions { - - } - - } - -
NameTypeBurr TypeNotesActions
- if props.OwnerHandle != "" { - { grinder.Name } - } else { - { grinder.Name } - } - - if grinder.GrinderType != "" { - { grinder.GrinderType } - } else { - - - } - - if grinder.BurrType != "" { - { grinder.BurrType } - } else { - - - } - - if grinder.Notes != "" { - { grinder.Notes } - } else { - - - } - - - -
+
+ for _, grinder := range props.Grinders { + @GrinderCard(grinder, props.ShowActions, props.OwnerHandle) + }
} } -// BrewersTableProps defines props for the shared brewers table +// GrinderCard renders a single grinder as a compact card +templ GrinderCard(grinder *models.Grinder, showActions bool, ownerHandle string) { +
+
+
+
+
+ if ownerHandle != "" { + { grinder.Name } + } else { + { grinder.Name } + } +
+
+ if showActions { +
+ + +
+ } +
+
+ if grinder.GrinderType != "" { + + @IconTag() + { grinder.GrinderType } + + } + if grinder.BurrType != "" { + + @IconDisc() + { grinder.BurrType } + + } +
+ if grinder.Notes != "" { +
"{ grinder.Notes }"
+ } +
+
+} + +// BrewersTableProps defines props for the shared brewers display type BrewersTableProps struct { Brewers []*models.Brewer ShowActions bool // Whether to show Edit/Delete actions OwnerHandle string // If set, name links to view page with this owner } -// BrewersTable renders a table of brewers with configurable actions +// BrewersTable renders a grid of brewer cards templ BrewersTable(props BrewersTableProps) { if len(props.Brewers) == 0 { @EmptyState(EmptyStateProps{Message: "No brewing devices yet."}) } else { -
- - - - - - - if props.ShowActions { - - } - - - - for _, brewer := range props.Brewers { - - - - - if props.ShowActions { - - } - - } - -
NameTypeDescriptionActions
- if props.OwnerHandle != "" { - { brewer.Name } - } else { - { brewer.Name } - } - - if brewer.BrewerType != "" { - { brewer.BrewerType } - } else { - - - } - - if brewer.Description != "" { - { brewer.Description } - } else { - - - } - - - -
+
+ for _, brewer := range props.Brewers { + @BrewerCard(brewer, props.ShowActions, props.OwnerHandle) + }
} } -// RecipesTableProps defines props for the shared recipes table +// BrewerCard renders a single brewer as a compact card +templ BrewerCard(brewer *models.Brewer, showActions bool, ownerHandle string) { +
+
+
+
+
+ if ownerHandle != "" { + { brewer.Name } + } else { + { brewer.Name } + } +
+
+ if showActions { +
+ + +
+ } +
+ if brewer.BrewerType != "" { +
+ + @IconBrewer() + { brewer.BrewerType } + +
+ } + if brewer.Description != "" { +
"{ brewer.Description }"
+ } +
+
+} + +// RecipesTableProps defines props for the shared recipes display type RecipesTableProps struct { Recipes []*models.Recipe ShowActions bool } -// RecipesTable renders a table of recipes with configurable actions +// RecipesTable renders a grid of recipe cards templ RecipesTable(props RecipesTableProps) { if len(props.Recipes) == 0 { @EmptyState(EmptyStateProps{Message: "No recipes yet."}) } else { -
- - - - - - - - if props.ShowActions { - - } - - - - for _, recipe := range props.Recipes { - - - - - - if props.ShowActions { - - } - - } - -
NameCoffeeWaterBrewerActions
- { recipe.Name } - - if recipe.CoffeeAmount > 0 { - { fmt.Sprintf("%.1fg", recipe.CoffeeAmount) } - } else { - - - } - - if recipe.WaterAmount > 0 { - { fmt.Sprintf("%.1fg", recipe.WaterAmount) } - } else { - - - } - - if recipe.BrewerObj != nil { - { recipe.BrewerObj.Name } - } else if recipe.BrewerType != "" { - { recipe.BrewerType } - } else { - - - } - - - -
+
+ for _, recipe := range props.Recipes { + @RecipeCard(recipe, props.ShowActions) + }
} } + +// RecipeCard renders a single recipe as a compact card +templ RecipeCard(recipe *models.Recipe, showActions bool) { +
+
+
+
+
{ recipe.Name }
+ if recipe.BrewerObj != nil { +
+ + @IconBrewer() + { recipe.BrewerObj.Name } + +
+ } else if recipe.BrewerType != "" { +
+ + @IconBrewer() + { recipe.BrewerType } + +
+ } +
+ if showActions { +
+ + +
+ } +
+
+ if recipe.CoffeeAmount > 0 { + + @IconScale() + { fmt.Sprintf("%.1fg coffee", recipe.CoffeeAmount) } + + } + if recipe.WaterAmount > 0 { + + @IconDroplet() + { fmt.Sprintf("%.1fg water", recipe.WaterAmount) } + + } +
+
+
+} diff --git a/internal/web/components/footer.templ b/internal/web/components/footer.templ index 1ce5eee..758b252 100644 --- a/internal/web/components/footer.templ +++ b/internal/web/components/footer.templ @@ -10,7 +10,7 @@ templ Footer() { diff --git a/internal/web/components/header.templ b/internal/web/components/header.templ index 6c8d484..7ab2eb8 100644 --- a/internal/web/components/header.templ +++ b/internal/web/components/header.templ @@ -28,7 +28,7 @@ templ HeaderWithProps(props HeaderProps) {
-

β˜• Arabica

+

Arabica

ALPHA
diff --git a/internal/web/components/icons.templ b/internal/web/components/icons.templ index a8c15fa..c0564b5 100644 --- a/internal/web/components/icons.templ +++ b/internal/web/components/icons.templ @@ -122,3 +122,84 @@ templ IconClock() { } + +// IconDisc renders a concentric disc icon (for burr types) +templ IconDisc() { + + + + +} + +// IconGlobe renders a globe icon (for websites/URLs) +templ IconGlobe() { + + + + + +} + +// IconCalendar renders a calendar icon (replaces πŸ“… emoji) +templ IconCalendar() { + + + + + + +} + +// IconWrench renders a wrench icon (replaces πŸ”§ emoji) +templ IconWrench() { + + + +} + +// IconGear renders a gear/settings icon (replaces βš™οΈ emoji) +templ IconGear() { + + + + +} + +// IconSliders renders a sliders/controls icon (replaces πŸŽ›οΈ emoji) +templ IconSliders() { + + + + + + + + + + + +} + +// IconLink renders a link/chain icon (replaces πŸ”— emoji) +templ IconLink() { + + + + +} + +// IconX renders a close/remove icon (replaces βœ• character) +templ IconX() { + + + + +} + +// IconTag renders a tag icon (for type/category metadata) +templ IconTag() { + + + + +} diff --git a/internal/web/components/layout.templ b/internal/web/components/layout.templ index 74f47c7..2a5d678 100644 --- a/internal/web/components/layout.templ +++ b/internal/web/components/layout.templ @@ -78,12 +78,12 @@ templ Layout(data *LayoutData, content templ.Component) { } - + { data.Title } - Arabica - + diff --git a/internal/web/components/record_brewer.templ b/internal/web/components/record_brewer.templ index bebfc57..3af4a1a 100644 --- a/internal/web/components/record_brewer.templ +++ b/internal/web/components/record_brewer.templ @@ -11,8 +11,8 @@ templ BrewerContent(brewer *models.Brewer) { { brewer.Name }
if brewer.BrewerType != "" { -
- +
+ @IconBrewer() { brewer.BrewerType } diff --git a/internal/web/components/record_grinder.templ b/internal/web/components/record_grinder.templ index 66d635d..2dd0f56 100644 --- a/internal/web/components/record_grinder.templ +++ b/internal/web/components/record_grinder.templ @@ -10,12 +10,18 @@ templ GrinderContent(grinder *models.Grinder) {
{ grinder.Name }
-
+
if grinder.GrinderType != "" { - βš™οΈ { grinder.GrinderType } + + @IconTag() + { grinder.GrinderType } + } if grinder.BurrType != "" { - πŸ”© { grinder.BurrType } + + @IconDisc() + { grinder.BurrType } + }
if grinder.Notes != "" { diff --git a/internal/web/components/record_roaster.templ b/internal/web/components/record_roaster.templ index 45c1f03..852c704 100644 --- a/internal/web/components/record_roaster.templ +++ b/internal/web/components/record_roaster.templ @@ -11,17 +11,18 @@ templ RoasterContent(roaster *models.Roaster) {
{ roaster.Name }
-
+
if roaster.Location != "" { - + @IconMapPin() { roaster.Location } } if roaster.Website != "" { if safeWebsite := bff.SafeWebsiteURL(roaster.Website); safeWebsite != "" { - - πŸ”— { safeWebsite } + + @IconGlobe() + { safeWebsite } } } diff --git a/internal/web/components/shared.templ b/internal/web/components/shared.templ index c88569d..6e741d6 100644 --- a/internal/web/components/shared.templ +++ b/internal/web/components/shared.templ @@ -25,39 +25,39 @@ templ BeanSummary(props BeanSummaryProps) {
if props.Bean.Roaster != nil && props.Bean.Roaster.Name != "" {
- + @IconStore() { props.Bean.Roaster.Name }
} -
+
if props.Bean.Origin != "" { - + @IconMapPin() { props.Bean.Origin } } if props.Bean.RoastLevel != "" { - + @IconFlame() { props.Bean.RoastLevel } } if props.Bean.Variety != "" { - + @IconLeaf() { props.Bean.Variety } } if props.Bean.Process != "" { - + @IconSprout() { props.Bean.Process } } if props.CoffeeAmount > 0 { - + @IconScale() { fmt.Sprintf("%dg", props.CoffeeAmount) } diff --git a/internal/web/pages/about.templ b/internal/web/pages/about.templ index 9cc49d0..f6dffa0 100644 --- a/internal/web/pages/about.templ +++ b/internal/web/pages/about.templ @@ -79,7 +79,7 @@ templ AboutContent(isAuthenticated bool) {

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. + Visit our GitHub repository to learn more.

diff --git a/internal/web/pages/brew_form.templ b/internal/web/pages/brew_form.templ index 963c64d..9ff6b01 100644 --- a/internal/web/pages/brew_form.templ +++ b/internal/web/pages/brew_form.templ @@ -505,7 +505,7 @@ templ PoursSection(props BrewFormProps) {

Track individual pours for bloom and subsequent additions

diff --git a/internal/web/pages/brew_list.templ b/internal/web/pages/brew_list.templ index a6f99e3..20aedcf 100644 --- a/internal/web/pages/brew_list.templ +++ b/internal/web/pages/brew_list.templ @@ -35,13 +35,13 @@ templ BrewListLoadingSkeleton() { - + - + - + diff --git a/internal/web/pages/brew_view.templ b/internal/web/pages/brew_view.templ index 1ec75b0..443fb6c 100644 --- a/internal/web/pages/brew_view.templ +++ b/internal/web/pages/brew_view.templ @@ -168,8 +168,8 @@ templ BrewParametersGrid(brew *models.Brew, owner string) {
@components.DetailField(components.DetailFieldProps{Icon: components.IconScale(), Label: "Coffee", Value: getCoffeeAmountDisplay(brew)}) @components.DetailField(components.DetailFieldProps{Icon: components.IconCoffee(), Label: "Brew Method", Value: getBrewerName(brew), LinkHref: getBrewerViewURL(brew, owner)}) - @components.DetailField(components.DetailFieldProps{Label: "βš™οΈ Grinder", Value: getGrinderName(brew), LinkHref: getGrinderViewURL(brew, owner)}) - @components.DetailField(components.DetailFieldProps{Label: "πŸ”© Grind Size", Value: getGrindSizeDisplay(brew)}) + @components.DetailField(components.DetailFieldProps{Icon: components.IconGear(), Label: "Grinder", Value: getGrinderName(brew), LinkHref: getGrinderViewURL(brew, owner)}) + @components.DetailField(components.DetailFieldProps{Icon: components.IconDisc(), Label: "Grind Size", Value: getGrindSizeDisplay(brew)}) @components.DetailField(components.DetailFieldProps{Icon: components.IconDroplet(), Label: "Water", Value: getWaterAmountDisplay(brew)}) @components.DetailField(components.DetailFieldProps{Icon: components.IconThermometer(), Label: "Temperature", Value: getTemperatureDisplay(brew)})
diff --git a/internal/web/pages/feed.templ b/internal/web/pages/feed.templ index d6acf2f..5f30858 100644 --- a/internal/web/pages/feed.templ +++ b/internal/web/pages/feed.templ @@ -354,33 +354,33 @@ templ FeedBrewContent(item *feed.FeedItem) {
if item.Brew.Bean.Roaster != nil && item.Brew.Bean.Roaster.Name != "" {
- + @components.IconStore() { item.Brew.Bean.Roaster.Name }
} -
+
if item.Brew.Bean.Origin != "" { - + @components.IconMapPin() { item.Brew.Bean.Origin } } if item.Brew.Bean.RoastLevel != "" { - + @components.IconFlame() { item.Brew.Bean.RoastLevel } } if item.Brew.Bean.Process != "" { - + @components.IconSprout() { item.Brew.Bean.Process } } if item.Brew.CoffeeAmount > 0 { - + @components.IconScale() { fmt.Sprintf("%dg", item.Brew.CoffeeAmount) } @@ -462,26 +462,26 @@ templ FeedRecipeContent(item *feed.FeedItem) {
{ item.Recipe.Name }
-
+
if item.Recipe.CoffeeAmount > 0 { - + @components.IconCoffee() { fmt.Sprintf("%.1fg", item.Recipe.CoffeeAmount) } } if item.Recipe.WaterAmount > 0 { - + @components.IconDroplet() { fmt.Sprintf("%.1fg", item.Recipe.WaterAmount) } } if item.Recipe.BrewerObj != nil { - + @components.IconBrewer() { item.Recipe.BrewerObj.Name } } else if item.Recipe.BrewerType != "" { - + @components.IconBrewer() { item.Recipe.BrewerType } diff --git a/internal/web/pages/grinder_view.templ b/internal/web/pages/grinder_view.templ index 0c59c8f..53b0bfb 100644 --- a/internal/web/pages/grinder_view.templ +++ b/internal/web/pages/grinder_view.templ @@ -40,8 +40,8 @@ templ GrinderViewCard(props GrinderViewProps) { @GrinderViewHeader(props)
- @components.DetailField(components.DetailFieldProps{Label: "βš™οΈ Type", Value: props.Grinder.GrinderType}) - @components.DetailField(components.DetailFieldProps{Label: "πŸ”© Burr Type", Value: props.Grinder.BurrType}) + @components.DetailField(components.DetailFieldProps{Icon: components.IconGear(), Label: "Type", Value: props.Grinder.GrinderType}) + @components.DetailField(components.DetailFieldProps{Icon: components.IconDisc(), Label: "Burr Type", Value: props.Grinder.BurrType})
if props.Grinder.Notes != "" {
diff --git a/internal/web/pages/home.templ b/internal/web/pages/home.templ index e0a4514..eaf7acb 100644 --- a/internal/web/pages/home.templ +++ b/internal/web/pages/home.templ @@ -23,18 +23,23 @@ templ HomeContent(props HomeProps) { if !props.IsAuthenticated { @components.AboutInfoCard() } - @CommunityFeedSection() + @CommunityFeedSection(props.IsAuthenticated) if props.IsAuthenticated { @components.AboutInfoCard() }
} -templ CommunityFeedSection() { +templ CommunityFeedSection(isAuthenticated bool) {

Community Feed

-
- @FeedLoadingSkeleton() + if isAuthenticated { + @FeedFilterBar(FeedQueryState{IsAuthenticated: isAuthenticated}) + } +
+
+ @FeedLoadingSkeleton() +
diff --git a/internal/web/pages/manage.templ b/internal/web/pages/manage.templ index 6ae2ae2..c200d91 100644 --- a/internal/web/pages/manage.templ +++ b/internal/web/pages/manage.templ @@ -89,13 +89,13 @@ templ ManageLoadingSkeleton() {
- + - + @@ -130,10 +130,10 @@ templ ManageLoadingSkeleton() {
πŸ“… Date@components.IconCalendar() Date @components.IconCoffee() Bean @components.IconBrewer() BrewerπŸ”§ Variables@components.IconWrench() Variables @components.IconFileText() Notes @components.IconStar() Ratingβš™οΈ Actions@components.IconGear() Actions
@components.IconBarChart() Status🏷️ Name@components.IconTag() Name @components.IconMapPin() Origin @components.IconCoffee() Roaster @components.IconFlame() Roast Level @components.IconSprout() Process @components.IconFileText() Descriptionβš™οΈ Actions@components.IconGear() Actions
- + - - + + @@ -164,11 +164,11 @@ templ ManageLoadingSkeleton() {
🏷️ Name@components.IconTag() Name @components.IconMapPin() Location🌐 Websiteβš™οΈ Actions@components.IconGlobe() Website@components.IconGear() Actions
- - - - - + + + + + @@ -200,9 +200,9 @@ templ ManageLoadingSkeleton() {
🏷️ NameπŸŽ›οΈ TypeπŸ”© Burr TypeπŸ“„ Notesβš™οΈ Actions@components.IconTag() Name@components.IconSliders() Type@components.IconDisc() Burr Type@components.IconFileText() Notes@components.IconGear() Actions
- - - + + + diff --git a/internal/web/pages/profile.templ b/internal/web/pages/profile.templ index 5edf97c..64fc568 100644 --- a/internal/web/pages/profile.templ +++ b/internal/web/pages/profile.templ @@ -132,13 +132,13 @@ templ ProfileLoadingSkeleton() {
🏷️ NameπŸ“„ Descriptionβš™οΈ Actions@components.IconTag() Name@components.IconFileText() Description@components.IconGear() Actions
- + - + - + diff --git a/internal/web/pages/roaster_view.templ b/internal/web/pages/roaster_view.templ index 85b71f9..8670e7a 100644 --- a/internal/web/pages/roaster_view.templ +++ b/internal/web/pages/roaster_view.templ @@ -43,7 +43,7 @@ templ RoasterViewCard(props RoasterViewProps) { @components.DetailField(components.DetailFieldProps{Icon: components.IconMapPin(), Label: "Location", Value: props.Roaster.Location}) if props.Roaster.Website != "" {
-

πŸ”— Website

+

@components.IconLink() Website

if safeWebsite := bff.SafeWebsiteURL(props.Roaster.Website); safeWebsite != "" { { safeWebsite } diff --git a/static/css/app.css b/static/css/app.css index c3f90c9..1739add 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -211,6 +211,7 @@ .bg-brown-50 { background-color: var(--page-bg) !important; } .bg-brown-100 { background-color: var(--surface-bg) !important; } .bg-brown-200 { background-color: var(--card-border) !important; } + .bg-brown-300 { background-color: var(--surface-bg) !important; } .border-brown-200 { border-color: var(--card-border) !important; } .border-brown-300 { border-color: var(--card-border) !important; } @@ -333,6 +334,7 @@ :root[data-theme="dark"] .bg-brown-50 { background-color: var(--page-bg) !important; } :root[data-theme="dark"] .bg-brown-100 { background-color: var(--surface-bg) !important; } :root[data-theme="dark"] .bg-brown-200 { background-color: var(--card-border) !important; } +:root[data-theme="dark"] .bg-brown-300 { background-color: var(--surface-bg) !important; } :root[data-theme="dark"] .border-brown-200, :root[data-theme="dark"] .border-brown-300 { border-color: var(--card-border) !important; } :root[data-theme="dark"] .bg-white { background-color: var(--card-bg) !important; } @@ -514,6 +516,16 @@ outline: none; } + /* Hide number input spinners */ + input[type="number"]::-webkit-inner-spin-button, + input[type="number"]::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; + } + input[type="number"] { + -moz-appearance: textfield; + } + .form-input::placeholder { color: var(--text-placeholder); } @@ -758,13 +770,13 @@ /* Badges */ .badge-rating { - @apply inline-flex items-center px-3 py-1 rounded-full text-sm font-medium flex-shrink-0; + @apply inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm font-medium flex-shrink-0; background: var(--rating-bg); color: var(--rating-text); } .badge-rating-sm { - @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium; + @apply inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium; background: var(--rating-bg); color: var(--rating-text); }
πŸ“… Date@components.IconCalendar() Date @components.IconCoffee() Bean @components.IconBrewer() BrewerπŸ”§ Variables@components.IconWrench() Variables @components.IconFileText() Notes @components.IconStar() Ratingβš™οΈ Actions@components.IconGear() Actions