feat: bootstrap brewlog platform
This commit is contained in:
commit
3241f3c961
51 changed files with 8720 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
target/
|
||||||
|
brewlog.db
|
||||||
|
TODO.md
|
||||||
|
SPEC.md
|
||||||
3253
Cargo.lock
generated
Normal file
3253
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
34
Cargo.toml
Normal file
34
Cargo.toml
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
[package]
|
||||||
|
name = "brewlog"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["sqlite"]
|
||||||
|
sqlite = ["sqlx/sqlite"]
|
||||||
|
postgres = ["sqlx/postgres"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0"
|
||||||
|
async-trait = "0.1"
|
||||||
|
axum = { version = "0.7", features = ["macros"] }
|
||||||
|
askama = "0.12"
|
||||||
|
block-id = "0.2.1"
|
||||||
|
chrono = { version = "0.4", features = ["serde", "clock"] }
|
||||||
|
clap = { version = "4.5", features = ["derive", "env"] }
|
||||||
|
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
once_cell = "1.19"
|
||||||
|
rand = "0.8"
|
||||||
|
sqlx = { version = "0.7", default-features = false, features = [
|
||||||
|
"runtime-tokio",
|
||||||
|
"macros",
|
||||||
|
"chrono",
|
||||||
|
"any",
|
||||||
|
"migrate",
|
||||||
|
] }
|
||||||
|
thiserror = "1.0"
|
||||||
|
tokio = { version = "1.38", features = ["rt-multi-thread", "macros", "signal"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
62
flake.lock
Normal file
62
flake.lock
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1763283776,
|
||||||
|
"narHash": "sha256-Y7TDFPK4GlqrKrivOcsHG8xSGqQx3A6c+i7novT85Uk=",
|
||||||
|
"owner": "nixos",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "50a96edd8d0db6cc8db57dab6bb6d6ee1f3dc49a",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nixos",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs_2": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1744536153,
|
||||||
|
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixpkgs-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs",
|
||||||
|
"rust-overlay": "rust-overlay"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rust-overlay": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs_2"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1763433504,
|
||||||
|
"narHash": "sha256-cVid5UNpk88sPYHkLAA5aZEHOFQXSB/2L1vl18Aq7IM=",
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"rev": "42ce16c6d8318a654d53f047c9400b7d902d6e61",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
108
flake.nix
Normal file
108
flake.nix
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
{
|
||||||
|
description = "brewlog";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||||
|
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
{
|
||||||
|
self,
|
||||||
|
nixpkgs,
|
||||||
|
rust-overlay,
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
supportedSystems = [
|
||||||
|
"x86_64-linux"
|
||||||
|
"aarch64-linux"
|
||||||
|
];
|
||||||
|
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
|
||||||
|
|
||||||
|
pkgsForSystem =
|
||||||
|
system:
|
||||||
|
(import nixpkgs {
|
||||||
|
inherit system;
|
||||||
|
overlays = [ (import rust-overlay) ];
|
||||||
|
});
|
||||||
|
in
|
||||||
|
{
|
||||||
|
packages = forAllSystems (
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
inherit (pkgsForSystem system)
|
||||||
|
lib
|
||||||
|
rustPlatform
|
||||||
|
pkg-config
|
||||||
|
openssl
|
||||||
|
;
|
||||||
|
|
||||||
|
cargoToml = lib.trivial.importTOML ./Cargo.toml;
|
||||||
|
version = cargoToml.package.version;
|
||||||
|
in
|
||||||
|
rec {
|
||||||
|
default = brewlog;
|
||||||
|
|
||||||
|
brewlog = rustPlatform.buildRustPackage {
|
||||||
|
pname = "brewlog";
|
||||||
|
version = version;
|
||||||
|
src = lib.cleanSource ./.;
|
||||||
|
cargoLock.lockFile = ./Cargo.lock;
|
||||||
|
|
||||||
|
nativeBuildInputs = [ pkg-config ];
|
||||||
|
|
||||||
|
buildInputs = [ openssl ];
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
description = "Log your favourite roasters, roasts, brews and cafes!";
|
||||||
|
homepage = "https://github.com/jnsgruk/brewlog";
|
||||||
|
license = lib.licenses.asl20;
|
||||||
|
mainProgram = "brewlog";
|
||||||
|
platforms = lib.platforms.unix;
|
||||||
|
maintainers = with lib.maintainers; [ jnsgruk ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
devShells = forAllSystems (
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
pkgs = pkgsForSystem system;
|
||||||
|
rust = pkgs.rust-bin.stable.latest.default.override {
|
||||||
|
extensions = [
|
||||||
|
"rust-src"
|
||||||
|
"clippy"
|
||||||
|
"rust-analyzer"
|
||||||
|
"rustfmt"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
default = pkgs.mkShell {
|
||||||
|
name = "brewlog";
|
||||||
|
|
||||||
|
NIX_CONFIG = "experimental-features = nix-command flakes";
|
||||||
|
RUST_SRC_PATH = "${rust}/lib/rustlib/src/rust/library";
|
||||||
|
LD_LIBRARY_PATH = with pkgs; lib.makeLibraryPath [ openssl ];
|
||||||
|
|
||||||
|
inputsFrom = [ self.packages.${system}.brewlog ];
|
||||||
|
buildInputs =
|
||||||
|
with pkgs;
|
||||||
|
[
|
||||||
|
cargo-watch
|
||||||
|
clang
|
||||||
|
lld
|
||||||
|
nil
|
||||||
|
nixfmt-rfc-style
|
||||||
|
sqlx-cli
|
||||||
|
sqlite
|
||||||
|
]
|
||||||
|
++ [
|
||||||
|
rust
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
93
migrations/0001_init.sql
Normal file
93
migrations/0001_init.sql
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
-- migrate:up
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
CREATE TABLE roasters (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
country TEXT NOT NULL,
|
||||||
|
city TEXT,
|
||||||
|
homepage TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE roasts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
roaster_id TEXT NOT NULL REFERENCES roasters(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
origin TEXT,
|
||||||
|
region TEXT,
|
||||||
|
producer TEXT,
|
||||||
|
process TEXT,
|
||||||
|
tasting_notes TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE cafes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
latitude REAL,
|
||||||
|
longitude REAL,
|
||||||
|
website TEXT,
|
||||||
|
address TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE gear (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
gear_type TEXT NOT NULL CHECK (gear_type IN ('grinder','brewer','kettle','filter_paper')),
|
||||||
|
manufacturer TEXT,
|
||||||
|
model TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE brews (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
roast_id TEXT NOT NULL REFERENCES roasts(id) ON DELETE RESTRICT,
|
||||||
|
method TEXT NOT NULL,
|
||||||
|
dose_grams REAL,
|
||||||
|
water_grams REAL,
|
||||||
|
brew_temperature_c REAL,
|
||||||
|
grind_setting TEXT,
|
||||||
|
brewed_at TEXT NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE brew_gear (
|
||||||
|
brew_id TEXT NOT NULL REFERENCES brews(id) ON DELETE CASCADE,
|
||||||
|
gear_id TEXT NOT NULL REFERENCES gear(id) ON DELETE RESTRICT,
|
||||||
|
PRIMARY KEY (brew_id, gear_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE cups (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
cafe_id TEXT NOT NULL REFERENCES cafes(id) ON DELETE RESTRICT,
|
||||||
|
roast_id TEXT REFERENCES roasts(id) ON DELETE SET NULL,
|
||||||
|
price_cents INTEGER,
|
||||||
|
consumed_at TEXT NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
rating INTEGER CHECK (rating BETWEEN 0 AND 255),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_roasts_roaster_id ON roasts(roaster_id);
|
||||||
|
CREATE INDEX idx_brews_roast_id ON brews(roast_id);
|
||||||
|
CREATE INDEX idx_brew_gear_gear_id ON brew_gear(gear_id);
|
||||||
|
CREATE INDEX idx_cups_cafe_id ON cups(cafe_id);
|
||||||
|
CREATE INDEX idx_cups_roast_id ON cups(roast_id);
|
||||||
|
|
||||||
|
CREATE TABLE timeline_events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
entity_type TEXT NOT NULL CHECK (entity_type IN ('roaster', 'roast')),
|
||||||
|
entity_id TEXT NOT NULL,
|
||||||
|
occurred_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
details_json TEXT,
|
||||||
|
tasting_notes_json TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_timeline_events_entity ON timeline_events(entity_type, entity_id);
|
||||||
|
CREATE INDEX idx_timeline_events_occurred_at ON timeline_events(occurred_at);
|
||||||
407
scripts/bootstrap-db.sh
Executable file
407
scripts/bootstrap-db.sh
Executable file
|
|
@ -0,0 +1,407 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
cargo build
|
||||||
|
|
||||||
|
# Tim Wendelboe (Norway)
|
||||||
|
./target/debug/brewlog add-roaster \
|
||||||
|
--name "Tim Wendelboe" \
|
||||||
|
--country "Norway" \
|
||||||
|
--city "Oslo" \
|
||||||
|
--homepage "https://timwendelboe.no" \
|
||||||
|
--notes "World-renowned Nordic micro-roastery dedicated to clarity and sustainability."
|
||||||
|
|
||||||
|
./target/debug/brewlog add-roast \
|
||||||
|
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Tim Wendelboe") | .id')" \
|
||||||
|
--name "Ben Saïd Natural" \
|
||||||
|
--origin "Ethiopia" \
|
||||||
|
--region "Sidamo" \
|
||||||
|
--producer "Ben Saïd" \
|
||||||
|
--process "Natural" \
|
||||||
|
--tasting-notes "Bergamot, Apricot, Floral"
|
||||||
|
|
||||||
|
./target/debug/brewlog add-roast \
|
||||||
|
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Tim Wendelboe") | .id')" \
|
||||||
|
--name "Finca Tamana Washed" \
|
||||||
|
--origin "Colombia" \
|
||||||
|
--region "El Pital, Huila" \
|
||||||
|
--producer "Elias Roa" \
|
||||||
|
--process "Washed" \
|
||||||
|
--tasting-notes "Red Apple, Vanilla, Caramel"
|
||||||
|
|
||||||
|
|
||||||
|
# Coffee Collective (Denmark)
|
||||||
|
./target/debug/brewlog add-roaster \
|
||||||
|
--name "Coffee Collective" \
|
||||||
|
--country "Denmark" \
|
||||||
|
--city "Copenhagen" \
|
||||||
|
--homepage "https://coffeecollective.dk" \
|
||||||
|
--notes "Pioneers of transparency and sustainability; multi-time Nordic roaster award winners."
|
||||||
|
|
||||||
|
./target/debug/brewlog add-roast \
|
||||||
|
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Coffee Collective") | .id')" \
|
||||||
|
--name "Daterra Sweet Collection" \
|
||||||
|
--origin "Brazil" \
|
||||||
|
--region "Cerrado" \
|
||||||
|
--producer "Daterra" \
|
||||||
|
--process "Pulped Natural" \
|
||||||
|
--tasting-notes "Hazelnut, Milk Chocolate, Yellow Fruit"
|
||||||
|
|
||||||
|
./target/debug/brewlog add-roast \
|
||||||
|
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Coffee Collective") | .id')" \
|
||||||
|
--name "Kieni" \
|
||||||
|
--origin "Kenya" \
|
||||||
|
--region "Nyeri" \
|
||||||
|
--producer "Kieni Factory" \
|
||||||
|
--process "Washed" \
|
||||||
|
--tasting-notes "Currant, Black Tea, Grape"
|
||||||
|
|
||||||
|
|
||||||
|
# Drop Coffee (Sweden)
|
||||||
|
./target/debug/brewlog add-roaster \
|
||||||
|
--name "Drop Coffee" \
|
||||||
|
--country "Sweden" \
|
||||||
|
--city "Stockholm" \
|
||||||
|
--homepage "https://dropcoffee.com" \
|
||||||
|
--notes "Award-winning Swedish roastery prized for its elegance and clean Scandinavian style."
|
||||||
|
|
||||||
|
./target/debug/brewlog add-roast \
|
||||||
|
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Drop Coffee") | .id')" \
|
||||||
|
--name "La Linda" \
|
||||||
|
--origin "Bolivia" \
|
||||||
|
--region "Caranavi" \
|
||||||
|
--producer "Pedro Rodriguez" \
|
||||||
|
--process "Washed" \
|
||||||
|
--tasting-notes "Red Apple, Caramel, Floral"
|
||||||
|
|
||||||
|
./target/debug/brewlog add-roast \
|
||||||
|
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Drop Coffee") | .id')" \
|
||||||
|
--name "El Sunzita" \
|
||||||
|
--origin "El Salvador" \
|
||||||
|
--region "Ahuachapan" \
|
||||||
|
--producer "Jorge Raul Rivera" \
|
||||||
|
--process "Natural" \
|
||||||
|
--tasting-notes "Strawberry, Mango, Dark Chocolate"
|
||||||
|
|
||||||
|
|
||||||
|
# La Cabra (Denmark)
|
||||||
|
./target/debug/brewlog add-roaster \
|
||||||
|
--name "La Cabra" \
|
||||||
|
--country "Denmark" \
|
||||||
|
--city "Aarhus" \
|
||||||
|
--homepage "https://www.lacabra.dk" \
|
||||||
|
--notes "Scandinavian minimalist roastery known for clarity and innovative sourcing."
|
||||||
|
|
||||||
|
./target/debug/brewlog add-roast \
|
||||||
|
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="La Cabra") | .id')" \
|
||||||
|
--name "Halo Beriti" \
|
||||||
|
--origin "Ethiopia" \
|
||||||
|
--region "Yirgacheffe" \
|
||||||
|
--producer "Halo Beriti Cooperative" \
|
||||||
|
--process "Washed" \
|
||||||
|
--tasting-notes "Jasmine, Lemon, Stone Fruit"
|
||||||
|
|
||||||
|
./target/debug/brewlog add-roast \
|
||||||
|
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="La Cabra") | .id')" \
|
||||||
|
--name "Cerro Azul" \
|
||||||
|
--origin "Colombia" \
|
||||||
|
--region "Valle del Cauca" \
|
||||||
|
--producer "Granja La Esperanza" \
|
||||||
|
--process "Washed" \
|
||||||
|
--tasting-notes "Blueberry, Plum, Grapefruit"
|
||||||
|
|
||||||
|
|
||||||
|
# April Coffee (Denmark)
|
||||||
|
./target/debug/brewlog add-roaster \
|
||||||
|
--name "April Coffee" \
|
||||||
|
--country "Denmark" \
|
||||||
|
--city "Copenhagen" \
|
||||||
|
--homepage "https://aprilcoffeeroasters.com" \
|
||||||
|
--notes "Modern approach to Nordic coffee, emphasizing transparency and traceability."
|
||||||
|
|
||||||
|
./target/debug/brewlog add-roast \
|
||||||
|
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="April Coffee") | .id')" \
|
||||||
|
--name "El Salvador Pacamara" \
|
||||||
|
--origin "El Salvador" \
|
||||||
|
--region "Santa Ana" \
|
||||||
|
--producer "Ernesto Menendez" \
|
||||||
|
--process "Honey" \
|
||||||
|
--tasting-notes "Grapefruit, Sugar Cane, Plum"
|
||||||
|
|
||||||
|
./target/debug/brewlog add-roast \
|
||||||
|
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="April Coffee") | .id')" \
|
||||||
|
--name "Guji Highland" \
|
||||||
|
--origin "Ethiopia" \
|
||||||
|
--region "Guji" \
|
||||||
|
--producer "Andualem Abebe" \
|
||||||
|
--process "Natural" \
|
||||||
|
--tasting-notes "Peach, Strawberry, Cream"
|
||||||
|
|
||||||
|
|
||||||
|
# Assembly Coffee (UK)
|
||||||
|
./target/debug/brewlog add-roaster \
|
||||||
|
--name "Assembly Coffee" \
|
||||||
|
--country "UK" \
|
||||||
|
--city "London" \
|
||||||
|
--homepage "https://assemblycoffee.co.uk" \
|
||||||
|
--notes "Based in Brixton, Assembly focuses on collaborative sourcing and education."
|
||||||
|
|
||||||
|
./target/debug/brewlog add-roast \
|
||||||
|
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Assembly Coffee") | .id')" \
|
||||||
|
--name "Kochere" \
|
||||||
|
--origin "Ethiopia" \
|
||||||
|
--region "Yirgacheffe" \
|
||||||
|
--producer "Kochere Region Growers" \
|
||||||
|
--process "Washed" \
|
||||||
|
--tasting-notes "Peach, Lemon, Jasmine"
|
||||||
|
|
||||||
|
./target/debug/brewlog add-roast \
|
||||||
|
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Assembly Coffee") | .id')" \
|
||||||
|
--name "La Laja" \
|
||||||
|
--origin "Mexico" \
|
||||||
|
--region "Veracruz" \
|
||||||
|
--producer "La Laja Estate" \
|
||||||
|
--process "Natural" \
|
||||||
|
--tasting-notes "Cherry, Milk Chocolate, Praline"
|
||||||
|
|
||||||
|
|
||||||
|
# Square Mile (UK)
|
||||||
|
./target/debug/brewlog add-roaster \
|
||||||
|
--name "Square Mile Coffee" \
|
||||||
|
--country "UK" \
|
||||||
|
--city "London" \
|
||||||
|
--homepage "https://squaremilecoffee.com" \
|
||||||
|
--notes "One of London's pioneers; delivers balanced and clear, fruit-forward coffees."
|
||||||
|
|
||||||
|
./target/debug/brewlog add-roast \
|
||||||
|
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Square Mile Coffee") | .id')" \
|
||||||
|
--name "Red Brick Espresso" \
|
||||||
|
--origin "Blend" \
|
||||||
|
--region "Multiple Origins" \
|
||||||
|
--producer "Various" \
|
||||||
|
--process "Washed, Natural" \
|
||||||
|
--tasting-notes "Berry, Chocolate, Citrus"
|
||||||
|
|
||||||
|
./target/debug/brewlog add-roast \
|
||||||
|
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Square Mile Coffee") | .id')" \
|
||||||
|
--name "Kamwangi" \
|
||||||
|
--origin "Kenya" \
|
||||||
|
--region "Kirinyaga" \
|
||||||
|
--producer "Kamwangi Factory" \
|
||||||
|
--process "Washed" \
|
||||||
|
--tasting-notes "Blackcurrant, Rhubarb, Blood Orange"
|
||||||
|
|
||||||
|
|
||||||
|
# Dak Coffee Roasters (Netherlands)
|
||||||
|
./target/debug/brewlog add-roaster \
|
||||||
|
--name "Dak Coffee Roasters" \
|
||||||
|
--country "Netherlands" \
|
||||||
|
--city "Amsterdam" \
|
||||||
|
--homepage "https://www.dakcoffeeroasters.com" \
|
||||||
|
--notes "Highly experimental Dutch roastery; celebrates vibrant acidity and alternative processing."
|
||||||
|
|
||||||
|
./target/debug/brewlog add-roast \
|
||||||
|
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Dak Coffee Roasters") | .id')" \
|
||||||
|
--name "El Paraiso 92 Anaerobic" \
|
||||||
|
--origin "Colombia" \
|
||||||
|
--region "Cauca" \
|
||||||
|
--producer "Diego Bermudez" \
|
||||||
|
--process "Thermal Shock Anaerobic" \
|
||||||
|
--tasting-notes "Passionfruit, Raspberry, Yogurt"
|
||||||
|
|
||||||
|
./target/debug/brewlog add-roast \
|
||||||
|
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Dak Coffee Roasters") | .id')" \
|
||||||
|
--name "Oreti SL28" \
|
||||||
|
--origin "Kenya" \
|
||||||
|
--region "Kirinyaga" \
|
||||||
|
--producer "Oreti Estate" \
|
||||||
|
--process "Washed" \
|
||||||
|
--tasting-notes "Grapefruit, Blackcurrant, Plum"
|
||||||
|
|
||||||
|
|
||||||
|
# Bonanza Coffee (Germany)
|
||||||
|
./target/debug/brewlog add-roaster \
|
||||||
|
--name "Bonanza Coffee" \
|
||||||
|
--country "Germany" \
|
||||||
|
--city "Berlin" \
|
||||||
|
--homepage "https://www.bonanzacoffee.de" \
|
||||||
|
--notes "Pioneering Berlin roastery focused on brightness, balance, and freshness."
|
||||||
|
|
||||||
|
./target/debug/brewlog add-roast \
|
||||||
|
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Bonanza Coffee") | .id')" \
|
||||||
|
--name "Gatomboya" \
|
||||||
|
--origin "Kenya" \
|
||||||
|
--region "Nyeri" \
|
||||||
|
--producer "Gatomboya Cooperative" \
|
||||||
|
--process "Washed" \
|
||||||
|
--tasting-notes "Blackcurrant, Lime, Tomato"
|
||||||
|
|
||||||
|
./target/debug/brewlog add-roast \
|
||||||
|
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Bonanza Coffee") | .id')" \
|
||||||
|
--name "Los Pirineos" \
|
||||||
|
--origin "El Salvador" \
|
||||||
|
--region "Usulután" \
|
||||||
|
--producer "Gilberto Baraona" \
|
||||||
|
--process "Honey" \
|
||||||
|
--tasting-notes "Maple, Fudge, Green Apple"
|
||||||
|
|
||||||
|
|
||||||
|
# Friedhats (Netherlands)
|
||||||
|
./target/debug/brewlog add-roaster \
|
||||||
|
--name "Friedhats" \
|
||||||
|
--country "Netherlands" \
|
||||||
|
--city "Amsterdam" \
|
||||||
|
--homepage "https://friedhats.com" \
|
||||||
|
--notes "Quirky branding meets serious, awarded, fruit-forward coffees from Amsterdam."
|
||||||
|
|
||||||
|
./target/debug/brewlog add-roast \
|
||||||
|
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Friedhats") | .id')" \
|
||||||
|
--name "Sidamo Guji" \
|
||||||
|
--origin "Ethiopia" \
|
||||||
|
--region "Guji" \
|
||||||
|
--producer "Smallholders" \
|
||||||
|
--process "Natural" \
|
||||||
|
--tasting-notes "Peach, Raspberry, Rosehip"
|
||||||
|
|
||||||
|
./target/debug/brewlog add-roast \
|
||||||
|
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Friedhats") | .id')" \
|
||||||
|
--name "La Esmeralda Geisha" \
|
||||||
|
--origin "Panama" \
|
||||||
|
--region "Boquete" \
|
||||||
|
--producer "Hacienda La Esmeralda" \
|
||||||
|
--process "Washed" \
|
||||||
|
--tasting-notes "Jasmine, Bergamot, Papaya"
|
||||||
|
|
||||||
|
|
||||||
|
# Origin Coffee (UK)
|
||||||
|
./target/debug/brewlog add-roaster \
|
||||||
|
--name "Origin Coffee" \
|
||||||
|
--country "UK" \
|
||||||
|
--city "Porthleven" \
|
||||||
|
--homepage "https://origincoffee.co.uk" \
|
||||||
|
--notes "Specialty roaster with close partnerships at origin; leading UK scene with cutting-edge lots."
|
||||||
|
|
||||||
|
./target/debug/brewlog add-roast \
|
||||||
|
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Origin Coffee") | .id')" \
|
||||||
|
--name "San Fermin" \
|
||||||
|
--origin "Colombia" \
|
||||||
|
--region "Tolima" \
|
||||||
|
--producer "San Fermin Smallholders" \
|
||||||
|
--process "Washed" \
|
||||||
|
--tasting-notes "Red Grape, Caramel, Blood Orange"
|
||||||
|
|
||||||
|
./target/debug/brewlog add-roast \
|
||||||
|
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Origin Coffee") | .id')" \
|
||||||
|
--name "Aricha" \
|
||||||
|
--origin "Ethiopia" \
|
||||||
|
--region "Yirgacheffe" \
|
||||||
|
--producer "Aricha Washing Station" \
|
||||||
|
--process "Washed" \
|
||||||
|
--tasting-notes "Honey, Peach, Black Tea"
|
||||||
|
|
||||||
|
|
||||||
|
# Dark Arts Coffee (UK)
|
||||||
|
./target/debug/brewlog add-roaster \
|
||||||
|
--name "Dark Arts Coffee" \
|
||||||
|
--country "UK" \
|
||||||
|
--city "London" \
|
||||||
|
--homepage "https://www.darkartscoffee.co.uk" \
|
||||||
|
--notes "Playful, disruptive roaster with a cult following and flavor-forward offerings."
|
||||||
|
|
||||||
|
./target/debug/brewlog add-roast \
|
||||||
|
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Dark Arts Coffee") | .id')" \
|
||||||
|
--name "Death to Decaf" \
|
||||||
|
--origin "Brazil" \
|
||||||
|
--region "Minas Gerais" \
|
||||||
|
--producer "Carmo de Minas" \
|
||||||
|
--process "Swiss Water Decaf" \
|
||||||
|
--tasting-notes "Cocoa, Cherry, Almond"
|
||||||
|
|
||||||
|
./target/debug/brewlog add-roast \
|
||||||
|
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Dark Arts Coffee") | .id')" \
|
||||||
|
--name "Snoop" \
|
||||||
|
--origin "Guatemala" \
|
||||||
|
--region "Huehuetenango" \
|
||||||
|
--producer "Various Smallholders" \
|
||||||
|
--process "Washed" \
|
||||||
|
--tasting-notes "Toffee, Green Apple, Plum"
|
||||||
|
|
||||||
|
|
||||||
|
# KAWA Coffee (France)
|
||||||
|
./target/debug/brewlog add-roaster \
|
||||||
|
--name "KAWA Coffee" \
|
||||||
|
--country "France" \
|
||||||
|
--city "Paris" \
|
||||||
|
--homepage "https://www.kawa.coffee" \
|
||||||
|
--notes "One of Paris’ most exciting specialty roasteries, known for unusual and competition-level lots."
|
||||||
|
|
||||||
|
./target/debug/brewlog add-roast \
|
||||||
|
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="KAWA Coffee") | .id')" \
|
||||||
|
--name "Sudan Rume" \
|
||||||
|
--origin "Colombia" \
|
||||||
|
--region "Cauca" \
|
||||||
|
--producer "Granja La Esperanza" \
|
||||||
|
--process "Natural" \
|
||||||
|
--tasting-notes "Strawberry, Cinnamon, Grape"
|
||||||
|
|
||||||
|
./target/debug/brewlog add-roast \
|
||||||
|
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="KAWA Coffee") | .id')" \
|
||||||
|
--name "Arbegona" \
|
||||||
|
--origin "Ethiopia" \
|
||||||
|
--region "Sidama" \
|
||||||
|
--producer "Arbegona Washing Station" \
|
||||||
|
--process "Washed" \
|
||||||
|
--tasting-notes "Violet, Apricot, Lemon"
|
||||||
|
|
||||||
|
|
||||||
|
# Stow Coffee (Slovenia)
|
||||||
|
./target/debug/brewlog add-roaster \
|
||||||
|
--name "Stow Coffee" \
|
||||||
|
--country "Slovenia" \
|
||||||
|
--city "Ljubljana" \
|
||||||
|
--homepage "https://www.stowcoffee.com" \
|
||||||
|
--notes "Slovenia’s specialty leader, awarded for pure, brightly acidic profiles and innovation."
|
||||||
|
|
||||||
|
./target/debug/brewlog add-roast \
|
||||||
|
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Stow Coffee") | .id')" \
|
||||||
|
--name "Santa Barbara" \
|
||||||
|
--origin "Honduras" \
|
||||||
|
--region "Santa Barbara" \
|
||||||
|
--producer "Benjamin Paz" \
|
||||||
|
--process "Honey" \
|
||||||
|
--tasting-notes "Red Currant, Honeydew, Cocoa"
|
||||||
|
|
||||||
|
./target/debug/brewlog add-roast \
|
||||||
|
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Stow Coffee") | .id')" \
|
||||||
|
--name "Suke Quto" \
|
||||||
|
--origin "Ethiopia" \
|
||||||
|
--region "Guji" \
|
||||||
|
--producer "Tesfaye Bekele" \
|
||||||
|
--process "Natural" \
|
||||||
|
--tasting-notes "Blackberry, Vanilla, Jasmine"
|
||||||
|
|
||||||
|
|
||||||
|
# Bows Coffee (Canada)
|
||||||
|
./target/debug/brewlog add-roaster \
|
||||||
|
--name "Bows Coffee" \
|
||||||
|
--country "Canada" \
|
||||||
|
--city "Victoria" \
|
||||||
|
--homepage "https://bowscoffee.com" \
|
||||||
|
--notes "Canadian micro-roaster with focus on clarity, complexity, and ethical sourcing."
|
||||||
|
|
||||||
|
./target/debug/brewlog add-roast \
|
||||||
|
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Bows Coffee") | .id')" \
|
||||||
|
--name "La Chumeca" \
|
||||||
|
--origin "Costa Rica" \
|
||||||
|
--region "Tarrazú" \
|
||||||
|
--producer "Doña Olga Jiménez" \
|
||||||
|
--process "White Honey" \
|
||||||
|
--tasting-notes "Mandarin, Honeycomb, Almond"
|
||||||
|
|
||||||
|
./target/debug/brewlog add-roast \
|
||||||
|
--roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Bows Coffee") | .id')" \
|
||||||
|
--name "Simbi" \
|
||||||
|
--origin "Rwanda" \
|
||||||
|
--region "Huye" \
|
||||||
|
--producer "Simbi Co-op" \
|
||||||
|
--process "Washed" \
|
||||||
|
--tasting-notes "Black Tea, Orange, Cane Sugar"
|
||||||
73
src/cli/mod.rs
Normal file
73
src/cli/mod.rs
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
pub mod roasters;
|
||||||
|
pub mod roasts;
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
use clap::{Args, Parser, Subcommand};
|
||||||
|
use roasters::{AddRoasterCommand, DeleteRoasterCommand, GetRoasterCommand, UpdateRoasterCommand};
|
||||||
|
use roasts::{AddRoastCommand, DeleteRoastCommand, GetRoastCommand, ListRoastsCommand};
|
||||||
|
|
||||||
|
#[derive(Debug, Parser)]
|
||||||
|
#[command(author, version, about = "Track coffee roasts, brews, and cups", long_about = None)]
|
||||||
|
pub struct Cli {
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
global = true,
|
||||||
|
env = "BREWLOG_URL",
|
||||||
|
default_value = "http://127.0.0.1:3000"
|
||||||
|
)]
|
||||||
|
pub api_url: String,
|
||||||
|
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
pub enum Commands {
|
||||||
|
#[command(name = "serve")]
|
||||||
|
Serve(ServeCommand),
|
||||||
|
|
||||||
|
// Roasters
|
||||||
|
#[command(name = "add-roaster")]
|
||||||
|
AddRoaster(AddRoasterCommand),
|
||||||
|
#[command(name = "list-roasters")]
|
||||||
|
ListRoasters,
|
||||||
|
#[command(name = "get-roaster")]
|
||||||
|
GetRoaster(GetRoasterCommand),
|
||||||
|
#[command(name = "update-roaster")]
|
||||||
|
UpdateRoaster(UpdateRoasterCommand),
|
||||||
|
#[command(name = "delete-roaster")]
|
||||||
|
DeleteRoaster(DeleteRoasterCommand),
|
||||||
|
|
||||||
|
// Roasts
|
||||||
|
#[command(name = "add-roast")]
|
||||||
|
AddRoast(AddRoastCommand),
|
||||||
|
#[command(name = "list-roasts")]
|
||||||
|
ListRoasts(ListRoastsCommand),
|
||||||
|
#[command(name = "get-roast")]
|
||||||
|
GetRoast(GetRoastCommand),
|
||||||
|
#[command(name = "delete-roast")]
|
||||||
|
DeleteRoast(DeleteRoastCommand),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct ServeCommand {
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
env = "BREWLOG_DATABASE_URL",
|
||||||
|
default_value = "sqlite://brewlog.db"
|
||||||
|
)]
|
||||||
|
pub database_url: String,
|
||||||
|
|
||||||
|
#[arg(long, env = "BREWLOG_BIND_ADDRESS", default_value = "127.0.0.1:3000")]
|
||||||
|
pub bind_address: SocketAddr,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn print_json<T>(value: &T) -> anyhow::Result<()>
|
||||||
|
where
|
||||||
|
T: serde::Serialize,
|
||||||
|
{
|
||||||
|
let rendered = serde_json::to_string_pretty(value)?;
|
||||||
|
println!("{rendered}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
96
src/cli/roasters.rs
Normal file
96
src/cli/roasters.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::Args;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use super::print_json;
|
||||||
|
use crate::client::BrewlogClient;
|
||||||
|
use crate::domain::roasters::{NewRoaster, UpdateRoaster};
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct AddRoasterCommand {
|
||||||
|
#[arg(long)]
|
||||||
|
pub name: String,
|
||||||
|
#[arg(long)]
|
||||||
|
pub country: String,
|
||||||
|
#[arg(long)]
|
||||||
|
pub city: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
pub homepage: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
pub notes: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_roaster(client: &BrewlogClient, command: AddRoasterCommand) -> Result<()> {
|
||||||
|
let payload = NewRoaster {
|
||||||
|
name: command.name,
|
||||||
|
country: command.country,
|
||||||
|
city: command.city,
|
||||||
|
homepage: command.homepage,
|
||||||
|
notes: command.notes,
|
||||||
|
};
|
||||||
|
|
||||||
|
let roaster = client.roasters().create(&payload).await?;
|
||||||
|
print_json(&roaster)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_roasters(client: &BrewlogClient) -> Result<()> {
|
||||||
|
let roasters = client.roasters().list().await?;
|
||||||
|
print_json(&roasters)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct GetRoasterCommand {
|
||||||
|
#[arg(long)]
|
||||||
|
pub id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_roaster(client: &BrewlogClient, command: GetRoasterCommand) -> Result<()> {
|
||||||
|
let roaster = client.roasters().get(&command.id).await?;
|
||||||
|
print_json(&roaster)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct UpdateRoasterCommand {
|
||||||
|
#[arg(long)]
|
||||||
|
pub id: String,
|
||||||
|
#[arg(long)]
|
||||||
|
pub name: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
pub country: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
pub city: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
pub homepage: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
pub notes: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_roaster(client: &BrewlogClient, command: UpdateRoasterCommand) -> Result<()> {
|
||||||
|
let payload = UpdateRoaster {
|
||||||
|
name: command.name,
|
||||||
|
country: command.country,
|
||||||
|
city: command.city,
|
||||||
|
homepage: command.homepage,
|
||||||
|
notes: command.notes,
|
||||||
|
};
|
||||||
|
|
||||||
|
let roaster = client.roasters().update(&command.id, &payload).await?;
|
||||||
|
print_json(&roaster)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct DeleteRoasterCommand {
|
||||||
|
#[arg(long)]
|
||||||
|
pub id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_roaster(client: &BrewlogClient, command: DeleteRoasterCommand) -> Result<()> {
|
||||||
|
let id = command.id;
|
||||||
|
client.roasters().delete(&id).await?;
|
||||||
|
let response = json!({
|
||||||
|
"status": "deleted",
|
||||||
|
"resource": "roaster",
|
||||||
|
"id": id,
|
||||||
|
});
|
||||||
|
print_json(&response)
|
||||||
|
}
|
||||||
79
src/cli/roasts.rs
Normal file
79
src/cli/roasts.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::Args;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use super::print_json;
|
||||||
|
use crate::client::BrewlogClient;
|
||||||
|
use crate::domain::roasts::NewRoast;
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct AddRoastCommand {
|
||||||
|
#[arg(long)]
|
||||||
|
pub roaster_id: String,
|
||||||
|
#[arg(long)]
|
||||||
|
pub name: String,
|
||||||
|
#[arg(long)]
|
||||||
|
pub origin: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
pub region: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
pub producer: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
pub process: Option<String>,
|
||||||
|
#[arg(long = "tasting-notes")]
|
||||||
|
pub tasting_notes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_roast(client: &BrewlogClient, command: AddRoastCommand) -> Result<()> {
|
||||||
|
let payload = NewRoast {
|
||||||
|
roaster_id: command.roaster_id,
|
||||||
|
name: command.name,
|
||||||
|
origin: command.origin,
|
||||||
|
region: command.region,
|
||||||
|
producer: command.producer,
|
||||||
|
tasting_notes: command.tasting_notes,
|
||||||
|
process: command.process,
|
||||||
|
};
|
||||||
|
|
||||||
|
let roast = client.roasts().create(&payload).await?;
|
||||||
|
print_json(&roast)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct ListRoastsCommand {
|
||||||
|
#[arg(long)]
|
||||||
|
pub roaster_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_roasts(client: &BrewlogClient, command: ListRoastsCommand) -> Result<()> {
|
||||||
|
let roasts = client.roasts().list(command.roaster_id.as_deref()).await?;
|
||||||
|
print_json(&roasts)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct GetRoastCommand {
|
||||||
|
#[arg(long)]
|
||||||
|
pub id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_roast(client: &BrewlogClient, command: GetRoastCommand) -> Result<()> {
|
||||||
|
let roast = client.roasts().get(&command.id).await?;
|
||||||
|
print_json(&roast)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct DeleteRoastCommand {
|
||||||
|
#[arg(long)]
|
||||||
|
pub id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_roast(client: &BrewlogClient, command: DeleteRoastCommand) -> Result<()> {
|
||||||
|
let id = command.id;
|
||||||
|
client.roasts().delete(&id).await?;
|
||||||
|
let response = json!({
|
||||||
|
"status": "deleted",
|
||||||
|
"resource": "roast",
|
||||||
|
"id": id,
|
||||||
|
});
|
||||||
|
print_json(&response)
|
||||||
|
}
|
||||||
80
src/client/mod.rs
Normal file
80
src/client/mod.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
pub mod roasters;
|
||||||
|
pub mod roasts;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, anyhow};
|
||||||
|
use reqwest::{Client, Url};
|
||||||
|
|
||||||
|
use crate::server::errors::ErrorResponse;
|
||||||
|
|
||||||
|
pub struct BrewlogClient {
|
||||||
|
base_url: Url,
|
||||||
|
http: Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BrewlogClient {
|
||||||
|
pub fn new(base_url: Url) -> Result<Self> {
|
||||||
|
let mut normalized = base_url;
|
||||||
|
if !normalized.path().ends_with('/') {
|
||||||
|
normalized.set_path(&format!("{}/", normalized.path().trim_end_matches('/')));
|
||||||
|
}
|
||||||
|
|
||||||
|
let http = Client::builder()
|
||||||
|
.user_agent("brewlog-cli/0.1")
|
||||||
|
.build()
|
||||||
|
.context("failed to configure HTTP client")?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
base_url: normalized,
|
||||||
|
http,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_base_url(base_url: &str) -> Result<Self> {
|
||||||
|
let url = Url::parse(base_url).with_context(|| format!("invalid API url: {base_url}"))?;
|
||||||
|
Self::new(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn roasters(&self) -> roasters::RoastersClient<'_> {
|
||||||
|
roasters::RoastersClient::new(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn roasts(&self) -> roasts::RoastsClient<'_> {
|
||||||
|
roasts::RoastsClient::new(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn endpoint(&self, path: &str) -> Result<Url> {
|
||||||
|
self.base_url
|
||||||
|
.join(path)
|
||||||
|
.with_context(|| format!("invalid API path: {path}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn http_client(&self) -> &Client {
|
||||||
|
&self.http
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn handle_response<T>(&self, response: reqwest::Response) -> Result<T>
|
||||||
|
where
|
||||||
|
T: serde::de::DeserializeOwned,
|
||||||
|
{
|
||||||
|
if response.status().is_success() {
|
||||||
|
response
|
||||||
|
.json::<T>()
|
||||||
|
.await
|
||||||
|
.context("failed to deserialize response body")
|
||||||
|
} else {
|
||||||
|
Err(self.response_error(response).await)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn response_error(&self, response: reqwest::Response) -> anyhow::Error {
|
||||||
|
let status = response.status();
|
||||||
|
let bytes = response.bytes().await.unwrap_or_default();
|
||||||
|
|
||||||
|
if let Ok(err) = serde_json::from_slice::<ErrorResponse>(&bytes) {
|
||||||
|
return anyhow!("request failed ({status}): {}", err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = String::from_utf8_lossy(&bytes);
|
||||||
|
anyhow!("request failed ({status}): {message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/client/roasters.rs
Normal file
86
src/client/roasters.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use reqwest::StatusCode;
|
||||||
|
|
||||||
|
use crate::domain::roasters::{NewRoaster, Roaster, UpdateRoaster};
|
||||||
|
|
||||||
|
use super::BrewlogClient;
|
||||||
|
|
||||||
|
pub struct RoastersClient<'a> {
|
||||||
|
inner: &'a BrewlogClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> RoastersClient<'a> {
|
||||||
|
pub(crate) fn new(inner: &'a BrewlogClient) -> Self {
|
||||||
|
Self { inner }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(&self, payload: &NewRoaster) -> Result<Roaster> {
|
||||||
|
let url = self.inner.endpoint("api/v1/roasters")?;
|
||||||
|
let response = self
|
||||||
|
.inner
|
||||||
|
.http_client()
|
||||||
|
.post(url)
|
||||||
|
.json(payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("failed to issue create roaster request")?;
|
||||||
|
|
||||||
|
self.inner.handle_response(response).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list(&self) -> Result<Vec<Roaster>> {
|
||||||
|
let url = self.inner.endpoint("api/v1/roasters")?;
|
||||||
|
let response = self
|
||||||
|
.inner
|
||||||
|
.http_client()
|
||||||
|
.get(url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("failed to issue list roasters request")?;
|
||||||
|
|
||||||
|
self.inner.handle_response(response).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(&self, id: &str) -> Result<Roaster> {
|
||||||
|
let url = self.inner.endpoint(&format!("api/v1/roasters/{id}"))?;
|
||||||
|
let response = self
|
||||||
|
.inner
|
||||||
|
.http_client()
|
||||||
|
.get(url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("failed to issue get roaster request")?;
|
||||||
|
|
||||||
|
self.inner.handle_response(response).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update(&self, id: &str, payload: &UpdateRoaster) -> Result<Roaster> {
|
||||||
|
let url = self.inner.endpoint(&format!("api/v1/roasters/{id}"))?;
|
||||||
|
let response = self
|
||||||
|
.inner
|
||||||
|
.http_client()
|
||||||
|
.put(url)
|
||||||
|
.json(payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("failed to issue update roaster request")?;
|
||||||
|
|
||||||
|
self.inner.handle_response(response).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(&self, id: &str) -> Result<()> {
|
||||||
|
let url = self.inner.endpoint(&format!("api/v1/roasters/{id}"))?;
|
||||||
|
let response = self
|
||||||
|
.inner
|
||||||
|
.http_client()
|
||||||
|
.delete(url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("failed to issue delete roaster request")?;
|
||||||
|
|
||||||
|
match response.status() {
|
||||||
|
StatusCode::NO_CONTENT => Ok(()),
|
||||||
|
_ => Err(self.inner.response_error(response).await),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/client/roasts.rs
Normal file
76
src/client/roasts.rs
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use reqwest::StatusCode;
|
||||||
|
|
||||||
|
use crate::domain::roasts::{NewRoast, Roast, RoastWithRoaster};
|
||||||
|
|
||||||
|
use super::BrewlogClient;
|
||||||
|
|
||||||
|
pub struct RoastsClient<'a> {
|
||||||
|
inner: &'a BrewlogClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> RoastsClient<'a> {
|
||||||
|
pub(crate) fn new(inner: &'a BrewlogClient) -> Self {
|
||||||
|
Self { inner }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(&self, payload: &NewRoast) -> Result<Roast> {
|
||||||
|
let url = self.inner.endpoint("api/v1/roasts")?;
|
||||||
|
let response = self
|
||||||
|
.inner
|
||||||
|
.http_client()
|
||||||
|
.post(url)
|
||||||
|
.json(payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("failed to issue create roast request")?;
|
||||||
|
|
||||||
|
self.inner.handle_response(response).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list(&self, roaster_id: Option<&str>) -> Result<Vec<RoastWithRoaster>> {
|
||||||
|
let mut url = self.inner.endpoint("api/v1/roasts")?;
|
||||||
|
if let Some(roaster_id) = roaster_id {
|
||||||
|
url.query_pairs_mut().append_pair("roaster_id", roaster_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.inner
|
||||||
|
.http_client()
|
||||||
|
.get(url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("failed to issue list roasts request")?;
|
||||||
|
|
||||||
|
self.inner.handle_response(response).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(&self, id: &str) -> Result<Roast> {
|
||||||
|
let url = self.inner.endpoint(&format!("api/v1/roasts/{id}"))?;
|
||||||
|
let response = self
|
||||||
|
.inner
|
||||||
|
.http_client()
|
||||||
|
.get(url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("failed to issue get roast request")?;
|
||||||
|
|
||||||
|
self.inner.handle_response(response).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(&self, id: &str) -> Result<()> {
|
||||||
|
let url = self.inner.endpoint(&format!("api/v1/roasts/{id}"))?;
|
||||||
|
let response = self
|
||||||
|
.inner
|
||||||
|
.http_client()
|
||||||
|
.delete(url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("failed to issue delete roast request")?;
|
||||||
|
|
||||||
|
match response.status() {
|
||||||
|
StatusCode::NO_CONTENT => Ok(()),
|
||||||
|
_ => Err(self.inner.response_error(response).await),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/domain/ids.rs
Normal file
14
src/domain/ids.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
use block_id::{Alphabet, BlockId as BlockIdGenerator};
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use rand::RngCore;
|
||||||
|
|
||||||
|
static ID_GENERATOR: Lazy<BlockIdGenerator<char>> =
|
||||||
|
Lazy::new(|| BlockIdGenerator::new(Alphabet::alphanumeric(), 0xB10C_1D_u128, 4));
|
||||||
|
|
||||||
|
pub fn generate_id() -> String {
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
let value = rng.next_u64();
|
||||||
|
ID_GENERATOR
|
||||||
|
.encode_string(value)
|
||||||
|
.expect("block-id encoding should succeed")
|
||||||
|
}
|
||||||
230
src/domain/listing.rs
Normal file
230
src/domain/listing.rs
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
use std::cmp;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||||
|
pub enum SortDirection {
|
||||||
|
Asc,
|
||||||
|
Desc,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SortDirection {
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
SortDirection::Asc => "asc",
|
||||||
|
SortDirection::Desc => "desc",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn opposite(self) -> Self {
|
||||||
|
match self {
|
||||||
|
SortDirection::Asc => SortDirection::Desc,
|
||||||
|
SortDirection::Desc => SortDirection::Asc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait SortKey: Copy + Eq {
|
||||||
|
fn default() -> Self;
|
||||||
|
fn from_query(value: &str) -> Option<Self>;
|
||||||
|
fn query_value(self) -> &'static str;
|
||||||
|
fn default_direction(self) -> SortDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||||
|
pub enum PageSize {
|
||||||
|
Limited(u32),
|
||||||
|
All,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PageSize {
|
||||||
|
pub fn limited(size: u32) -> Self {
|
||||||
|
if size == 0 {
|
||||||
|
PageSize::All
|
||||||
|
} else {
|
||||||
|
PageSize::Limited(size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn is_all(self) -> bool {
|
||||||
|
matches!(self, PageSize::All)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn as_option(self) -> Option<u32> {
|
||||||
|
match self {
|
||||||
|
PageSize::Limited(value) => Some(value),
|
||||||
|
PageSize::All => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_query_value(self) -> String {
|
||||||
|
match self {
|
||||||
|
PageSize::All => "all".to_string(),
|
||||||
|
PageSize::Limited(value) => value.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const DEFAULT_PAGE_SIZE: u32 = 10;
|
||||||
|
pub const MAX_PAGE_SIZE: u32 = 50;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||||
|
pub struct ListRequest<K: SortKey> {
|
||||||
|
page: u32,
|
||||||
|
page_size: PageSize,
|
||||||
|
sort_key: K,
|
||||||
|
sort_direction: SortDirection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<K: SortKey> ListRequest<K> {
|
||||||
|
pub fn new(page: u32, page_size: PageSize, sort_key: K, sort_direction: SortDirection) -> Self {
|
||||||
|
let page = page.max(1);
|
||||||
|
let page_size = match page_size {
|
||||||
|
PageSize::Limited(size) => {
|
||||||
|
if size == 0 {
|
||||||
|
PageSize::All
|
||||||
|
} else {
|
||||||
|
let clamped = cmp::min(size, MAX_PAGE_SIZE).max(1);
|
||||||
|
PageSize::Limited(clamped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PageSize::All => PageSize::All,
|
||||||
|
};
|
||||||
|
|
||||||
|
Self {
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
sort_key,
|
||||||
|
sort_direction,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default() -> Self {
|
||||||
|
let key = K::default();
|
||||||
|
Self::new(
|
||||||
|
1,
|
||||||
|
PageSize::Limited(DEFAULT_PAGE_SIZE),
|
||||||
|
key,
|
||||||
|
key.default_direction(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show_all(sort_key: K, sort_direction: SortDirection) -> Self {
|
||||||
|
Self::new(1, PageSize::All, sort_key, sort_direction)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn page(&self) -> u32 {
|
||||||
|
self.page
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn page_size(&self) -> PageSize {
|
||||||
|
self.page_size
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn sort_key(&self) -> K {
|
||||||
|
self.sort_key
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn sort_direction(&self) -> SortDirection {
|
||||||
|
self.sort_direction
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_page(self, page: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
page: page.max(1),
|
||||||
|
..self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_page_size(self, page_size: PageSize) -> Self {
|
||||||
|
Self::new(self.page, page_size, self.sort_key, self.sort_direction)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_sort(self, key: K) -> Self {
|
||||||
|
let direction = if key == self.sort_key {
|
||||||
|
self.sort_direction.opposite()
|
||||||
|
} else {
|
||||||
|
key.default_direction()
|
||||||
|
};
|
||||||
|
Self::new(self.page, self.page_size, key, direction)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_sort_and_direction(self, key: K, direction: SortDirection) -> Self {
|
||||||
|
Self::new(self.page, self.page_size, key, direction)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_page_within(self, total: u64) -> Self {
|
||||||
|
if matches!(self.page_size, PageSize::All) {
|
||||||
|
return Self::new(1, PageSize::All, self.sort_key, self.sort_direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(limit) = self.page_size.as_option() else {
|
||||||
|
return self;
|
||||||
|
};
|
||||||
|
|
||||||
|
if total == 0 {
|
||||||
|
return Self::new(1, self.page_size, self.sort_key, self.sort_direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
let last_page = ((total + limit as u64 - 1) / limit as u64) as u32;
|
||||||
|
let adjusted_page = self.page.min(last_page.max(1));
|
||||||
|
Self::new(
|
||||||
|
adjusted_page,
|
||||||
|
self.page_size,
|
||||||
|
self.sort_key,
|
||||||
|
self.sort_direction,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Page<T> {
|
||||||
|
pub items: Vec<T>,
|
||||||
|
pub page: u32,
|
||||||
|
pub page_size: u32,
|
||||||
|
pub total: u64,
|
||||||
|
pub showing_all: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Page<T> {
|
||||||
|
pub fn new(items: Vec<T>, page: u32, page_size: u32, total: u64, showing_all: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
items,
|
||||||
|
page: page.max(1),
|
||||||
|
page_size: page_size.max(1),
|
||||||
|
total,
|
||||||
|
showing_all,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn total_pages(&self) -> u32 {
|
||||||
|
if self.total == 0 || self.showing_all {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
let size = self.page_size as u64;
|
||||||
|
((self.total + size - 1) / size) as u32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_previous(&self) -> bool {
|
||||||
|
!self.showing_all && self.page > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_next(&self) -> bool {
|
||||||
|
!self.showing_all && self.page < self.total_pages()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_index(&self) -> u64 {
|
||||||
|
if self.total == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
((self.page - 1) as u64) * self.page_size as u64 + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn end_index(&self) -> u64 {
|
||||||
|
if self.total == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
self.start_index() + self.items.len() as u64 - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/domain/mod.rs
Normal file
32
src/domain/mod.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
pub mod ids;
|
||||||
|
pub mod listing;
|
||||||
|
pub mod origins;
|
||||||
|
pub mod repositories;
|
||||||
|
pub mod roasters;
|
||||||
|
pub mod roasts;
|
||||||
|
pub mod timeline;
|
||||||
|
use std::fmt::Display;
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
// TODO: This should probably be in a file named errors.rs
|
||||||
|
// so that it's domain::errors::RepositoryError.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum RepositoryError {
|
||||||
|
#[error("entity not found")]
|
||||||
|
NotFound,
|
||||||
|
#[error("conflict: {0}")]
|
||||||
|
Conflict(String),
|
||||||
|
#[error("unexpected data store error: {0}")]
|
||||||
|
Unexpected(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RepositoryError {
|
||||||
|
pub fn conflict<T: Display>(message: T) -> Self {
|
||||||
|
Self::Conflict(message.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unexpected<T: Display>(message: T) -> Self {
|
||||||
|
Self::Unexpected(message.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/domain/origins.rs
Normal file
13
src/domain/origins.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Origin {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub country: String,
|
||||||
|
pub region: Option<String>,
|
||||||
|
pub elevation_masl: Option<u16>,
|
||||||
|
pub notes: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
67
src/domain/repositories.rs
Normal file
67
src/domain/repositories.rs
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
use super::RepositoryError;
|
||||||
|
use crate::domain::listing::{ListRequest, Page, SortDirection, SortKey};
|
||||||
|
|
||||||
|
use crate::domain::roasters::RoasterSortKey;
|
||||||
|
use crate::domain::roasters::{Roaster, UpdateRoaster};
|
||||||
|
use crate::domain::roasts::RoastSortKey;
|
||||||
|
use crate::domain::roasts::{Roast, RoastWithRoaster, UpdateRoast};
|
||||||
|
use crate::domain::timeline::TimelineEvent;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait RoasterRepository: Send + Sync {
|
||||||
|
async fn insert(&self, roaster: Roaster) -> Result<Roaster, RepositoryError>;
|
||||||
|
async fn get(&self, id: String) -> Result<Roaster, RepositoryError>;
|
||||||
|
async fn list(
|
||||||
|
&self,
|
||||||
|
request: &ListRequest<RoasterSortKey>,
|
||||||
|
) -> Result<Page<Roaster>, RepositoryError>;
|
||||||
|
async fn update(&self, id: String, changes: UpdateRoaster) -> Result<Roaster, RepositoryError>;
|
||||||
|
async fn delete(&self, id: String) -> Result<(), RepositoryError>;
|
||||||
|
|
||||||
|
async fn list_all(&self) -> Result<Vec<Roaster>, RepositoryError> {
|
||||||
|
let sort_key = <RoasterSortKey as SortKey>::default();
|
||||||
|
let request =
|
||||||
|
ListRequest::<RoasterSortKey>::show_all(sort_key, sort_key.default_direction());
|
||||||
|
let page = self.list(&request).await?;
|
||||||
|
Ok(page.items)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_all_sorted(
|
||||||
|
&self,
|
||||||
|
sort_key: RoasterSortKey,
|
||||||
|
direction: SortDirection,
|
||||||
|
) -> Result<Vec<Roaster>, RepositoryError> {
|
||||||
|
let request = ListRequest::show_all(sort_key, direction);
|
||||||
|
let page = self.list(&request).await?;
|
||||||
|
Ok(page.items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait RoastRepository: Send + Sync {
|
||||||
|
async fn insert(&self, roast: Roast) -> Result<Roast, RepositoryError>;
|
||||||
|
async fn get(&self, id: String) -> Result<Roast, RepositoryError>;
|
||||||
|
async fn list(
|
||||||
|
&self,
|
||||||
|
request: &ListRequest<RoastSortKey>,
|
||||||
|
) -> Result<Page<RoastWithRoaster>, RepositoryError>;
|
||||||
|
async fn list_by_roaster(
|
||||||
|
&self,
|
||||||
|
roaster_id: String,
|
||||||
|
) -> Result<Vec<RoastWithRoaster>, RepositoryError>;
|
||||||
|
async fn update(&self, id: String, changes: UpdateRoast) -> Result<Roast, RepositoryError>;
|
||||||
|
async fn delete(&self, id: String) -> Result<(), RepositoryError>;
|
||||||
|
|
||||||
|
async fn list_all(&self) -> Result<Vec<RoastWithRoaster>, RepositoryError> {
|
||||||
|
let sort_key = <RoastSortKey as SortKey>::default();
|
||||||
|
let request = ListRequest::<RoastSortKey>::show_all(sort_key, sort_key.default_direction());
|
||||||
|
let page = self.list(&request).await?;
|
||||||
|
Ok(page.items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait TimelineEventRepository: Send + Sync {
|
||||||
|
async fn list_all(&self) -> Result<Vec<TimelineEvent>, RepositoryError>;
|
||||||
|
}
|
||||||
108
src/domain/roasters.rs
Normal file
108
src/domain/roasters.rs
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::domain::ids::generate_id;
|
||||||
|
use crate::domain::listing::{SortDirection, SortKey};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Roaster {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub country: String,
|
||||||
|
pub city: Option<String>,
|
||||||
|
pub homepage: Option<String>,
|
||||||
|
pub notes: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct NewRoaster {
|
||||||
|
pub name: String,
|
||||||
|
pub country: String,
|
||||||
|
pub city: Option<String>,
|
||||||
|
pub homepage: Option<String>,
|
||||||
|
pub notes: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NewRoaster {
|
||||||
|
pub fn normalize(mut self) -> Self {
|
||||||
|
self.name = self.name.trim().to_string();
|
||||||
|
self.country = self.country.trim().to_string();
|
||||||
|
self.city = normalize_optional_field(self.city);
|
||||||
|
self.homepage = normalize_optional_field(self.homepage);
|
||||||
|
self.notes = normalize_optional_field(self.notes);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_roaster(self) -> Roaster {
|
||||||
|
Roaster {
|
||||||
|
id: generate_id(),
|
||||||
|
name: self.name,
|
||||||
|
country: self.country,
|
||||||
|
city: self.city,
|
||||||
|
homepage: self.homepage,
|
||||||
|
notes: self.notes,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_optional_field(value: Option<String>) -> Option<String> {
|
||||||
|
value.and_then(|raw| {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed.to_string())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct UpdateRoaster {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub country: Option<String>,
|
||||||
|
pub city: Option<String>,
|
||||||
|
pub homepage: Option<String>,
|
||||||
|
pub notes: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||||
|
pub enum RoasterSortKey {
|
||||||
|
CreatedAt,
|
||||||
|
Name,
|
||||||
|
Country,
|
||||||
|
City,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SortKey for RoasterSortKey {
|
||||||
|
fn default() -> Self {
|
||||||
|
RoasterSortKey::CreatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_query(value: &str) -> Option<Self> {
|
||||||
|
match value {
|
||||||
|
"created-at" => Some(RoasterSortKey::CreatedAt),
|
||||||
|
"name" => Some(RoasterSortKey::Name),
|
||||||
|
"country" => Some(RoasterSortKey::Country),
|
||||||
|
"city" => Some(RoasterSortKey::City),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_value(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
RoasterSortKey::CreatedAt => "created-at",
|
||||||
|
RoasterSortKey::Name => "name",
|
||||||
|
RoasterSortKey::Country => "country",
|
||||||
|
RoasterSortKey::City => "city",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_direction(self) -> SortDirection {
|
||||||
|
match self {
|
||||||
|
RoasterSortKey::CreatedAt => SortDirection::Desc,
|
||||||
|
_ => SortDirection::Asc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
105
src/domain/roasts.rs
Normal file
105
src/domain/roasts.rs
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::domain::ids::generate_id;
|
||||||
|
use crate::domain::listing::{SortDirection, SortKey};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Roast {
|
||||||
|
pub id: String,
|
||||||
|
pub roaster_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub origin: Option<String>,
|
||||||
|
pub region: Option<String>,
|
||||||
|
pub producer: Option<String>,
|
||||||
|
pub tasting_notes: Vec<String>,
|
||||||
|
pub process: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RoastWithRoaster {
|
||||||
|
pub roast: Roast,
|
||||||
|
pub roaster_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct NewRoast {
|
||||||
|
pub roaster_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub origin: Option<String>,
|
||||||
|
pub region: Option<String>,
|
||||||
|
pub producer: Option<String>,
|
||||||
|
pub tasting_notes: Vec<String>,
|
||||||
|
pub process: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NewRoast {
|
||||||
|
pub fn into_roast(self) -> Roast {
|
||||||
|
Roast {
|
||||||
|
id: generate_id(),
|
||||||
|
roaster_id: self.roaster_id,
|
||||||
|
name: self.name,
|
||||||
|
origin: self.origin,
|
||||||
|
region: self.region,
|
||||||
|
producer: self.producer,
|
||||||
|
tasting_notes: self.tasting_notes,
|
||||||
|
process: self.process,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct UpdateRoast {
|
||||||
|
pub roaster_id: Option<String>,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub origin: Option<String>,
|
||||||
|
pub region: Option<String>,
|
||||||
|
pub producer: Option<String>,
|
||||||
|
pub tasting_notes: Option<Vec<String>>,
|
||||||
|
pub process: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||||
|
pub enum RoastSortKey {
|
||||||
|
CreatedAt,
|
||||||
|
Name,
|
||||||
|
Roaster,
|
||||||
|
Origin,
|
||||||
|
Producer,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SortKey for RoastSortKey {
|
||||||
|
fn default() -> Self {
|
||||||
|
RoastSortKey::CreatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_query(value: &str) -> Option<Self> {
|
||||||
|
match value {
|
||||||
|
"created-at" => Some(RoastSortKey::CreatedAt),
|
||||||
|
"name" => Some(RoastSortKey::Name),
|
||||||
|
"roaster" => Some(RoastSortKey::Roaster),
|
||||||
|
"origin" => Some(RoastSortKey::Origin),
|
||||||
|
"producer" => Some(RoastSortKey::Producer),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_value(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
RoastSortKey::CreatedAt => "created-at",
|
||||||
|
RoastSortKey::Name => "name",
|
||||||
|
RoastSortKey::Roaster => "roaster",
|
||||||
|
RoastSortKey::Origin => "origin",
|
||||||
|
RoastSortKey::Producer => "producer",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_direction(self) -> SortDirection {
|
||||||
|
match self {
|
||||||
|
RoastSortKey::CreatedAt => SortDirection::Desc,
|
||||||
|
_ => SortDirection::Asc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/domain/timeline.rs
Normal file
29
src/domain/timeline.rs
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TimelineEventDetail {
|
||||||
|
pub label: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TimelineEvent {
|
||||||
|
pub id: String,
|
||||||
|
pub entity_type: String,
|
||||||
|
pub entity_id: String,
|
||||||
|
pub occurred_at: DateTime<Utc>,
|
||||||
|
pub title: String,
|
||||||
|
pub details: Vec<TimelineEventDetail>,
|
||||||
|
pub tasting_notes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct NewTimelineEvent {
|
||||||
|
pub entity_type: String,
|
||||||
|
pub entity_id: String,
|
||||||
|
pub occurred_at: DateTime<Utc>,
|
||||||
|
pub title: String,
|
||||||
|
pub details: Vec<TimelineEventDetail>,
|
||||||
|
pub tasting_notes: Vec<String>,
|
||||||
|
}
|
||||||
81
src/infrastructure/database.rs
Normal file
81
src/infrastructure/database.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
use anyhow::Context;
|
||||||
|
use sqlx::migrate::Migrator;
|
||||||
|
|
||||||
|
#[cfg(all(feature = "sqlite", feature = "postgres"))]
|
||||||
|
compile_error!("features `sqlite` and `postgres` cannot both be enabled");
|
||||||
|
|
||||||
|
#[cfg(not(any(feature = "sqlite", feature = "postgres")))]
|
||||||
|
compile_error!("enable either the `sqlite` or `postgres` feature");
|
||||||
|
|
||||||
|
#[cfg(feature = "sqlite")]
|
||||||
|
pub type DatabasePool = sqlx::SqlitePool;
|
||||||
|
#[cfg(feature = "sqlite")]
|
||||||
|
type PoolOptions = sqlx::sqlite::SqlitePoolOptions;
|
||||||
|
#[cfg(feature = "sqlite")]
|
||||||
|
pub type DatabaseTransaction<'a> = sqlx::Transaction<'a, sqlx::Sqlite>;
|
||||||
|
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
pub type DatabasePool = sqlx::PgPool;
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
type PoolOptions = sqlx::postgres::PgPoolOptions;
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
pub type DatabaseTransaction<'a> = sqlx::Transaction<'a, sqlx::Postgres>;
|
||||||
|
|
||||||
|
pub struct Database {
|
||||||
|
pool: DatabasePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
pub async fn connect(database_url: &str) -> anyhow::Result<Self> {
|
||||||
|
#[cfg(feature = "sqlite")]
|
||||||
|
let pool = {
|
||||||
|
use sqlx::sqlite::SqliteConnectOptions;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
let options = SqliteConnectOptions::from_str(database_url)
|
||||||
|
.with_context(|| format!("invalid database url: {database_url}"))?
|
||||||
|
.create_if_missing(true);
|
||||||
|
|
||||||
|
PoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect_with(options)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("failed to connect to database: {database_url}"))?
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
let pool = PoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(database_url)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("failed to connect to database: {database_url}"))?;
|
||||||
|
|
||||||
|
#[cfg(feature = "sqlite")]
|
||||||
|
{
|
||||||
|
sqlx::query("PRAGMA foreign_keys = ON;")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.context("failed to enable foreign keys for sqlite")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let db = Self { pool };
|
||||||
|
db.migrate().await?;
|
||||||
|
Ok(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pool(&self) -> &DatabasePool {
|
||||||
|
&self.pool
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clone_pool(&self) -> DatabasePool {
|
||||||
|
self.pool.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn migrate(&self) -> anyhow::Result<()> {
|
||||||
|
static MIGRATOR: Migrator = sqlx::migrate!("./migrations");
|
||||||
|
MIGRATOR
|
||||||
|
.run(&self.pool)
|
||||||
|
.await
|
||||||
|
.context("database migration failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/infrastructure/mod.rs
Normal file
2
src/infrastructure/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod database;
|
||||||
|
pub mod repositories;
|
||||||
3
src/infrastructure/repositories/mod.rs
Normal file
3
src/infrastructure/repositories/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
pub mod roasters;
|
||||||
|
pub mod roasts;
|
||||||
|
pub mod timeline_events;
|
||||||
314
src/infrastructure/repositories/roasters.rs
Normal file
314
src/infrastructure/repositories/roasters.rs
Normal file
|
|
@ -0,0 +1,314 @@
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use sqlx::{QueryBuilder, query_as, query_scalar};
|
||||||
|
|
||||||
|
use crate::domain::RepositoryError;
|
||||||
|
use crate::domain::ids::generate_id;
|
||||||
|
use crate::domain::listing::{ListRequest, Page, PageSize, SortDirection};
|
||||||
|
use crate::domain::repositories::RoasterRepository;
|
||||||
|
use crate::domain::roasters::{Roaster, RoasterSortKey, UpdateRoaster};
|
||||||
|
use crate::domain::timeline::TimelineEventDetail;
|
||||||
|
use crate::infrastructure::database::DatabasePool;
|
||||||
|
|
||||||
|
type DbId = String;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SqlRoasterRepository {
|
||||||
|
pool: DatabasePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqlRoasterRepository {
|
||||||
|
pub fn new(pool: DatabasePool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_domain(record: RoasterRecord) -> Result<Roaster, RepositoryError> {
|
||||||
|
let RoasterRecord {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
country,
|
||||||
|
city,
|
||||||
|
homepage,
|
||||||
|
notes,
|
||||||
|
created_at,
|
||||||
|
} = record;
|
||||||
|
|
||||||
|
Ok(Roaster {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
country,
|
||||||
|
city,
|
||||||
|
homepage,
|
||||||
|
notes,
|
||||||
|
created_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn roaster_order_clause(request: &ListRequest<RoasterSortKey>) -> String {
|
||||||
|
let dir_sql = match request.sort_direction() {
|
||||||
|
SortDirection::Asc => "ASC",
|
||||||
|
SortDirection::Desc => "DESC",
|
||||||
|
};
|
||||||
|
|
||||||
|
match request.sort_key() {
|
||||||
|
RoasterSortKey::CreatedAt => format!("created_at {dir_sql}, name ASC"),
|
||||||
|
RoasterSortKey::Name => format!("LOWER(name) {dir_sql}, created_at DESC"),
|
||||||
|
RoasterSortKey::Country => format!("LOWER(country) {dir_sql}, LOWER(name) ASC"),
|
||||||
|
RoasterSortKey::City => {
|
||||||
|
format!("LOWER(COALESCE(city, '')) {dir_sql}, LOWER(name) ASC")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl RoasterRepository for SqlRoasterRepository {
|
||||||
|
async fn insert(&self, roaster: Roaster) -> Result<Roaster, RepositoryError> {
|
||||||
|
let mut tx = self
|
||||||
|
.pool
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
let query = "INSERT INTO roasters (id, name, country, city, homepage, notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)";
|
||||||
|
|
||||||
|
sqlx::query(query)
|
||||||
|
.bind(&roaster.id)
|
||||||
|
.bind(&roaster.name)
|
||||||
|
.bind(&roaster.country)
|
||||||
|
.bind(&roaster.city)
|
||||||
|
.bind(&roaster.homepage)
|
||||||
|
.bind(&roaster.notes)
|
||||||
|
.bind(roaster.created_at)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
let homepage_value = roaster
|
||||||
|
.homepage
|
||||||
|
.as_ref()
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "—".to_string());
|
||||||
|
|
||||||
|
let details = vec![
|
||||||
|
TimelineEventDetail {
|
||||||
|
label: "Country".to_string(),
|
||||||
|
value: roaster.country.clone(),
|
||||||
|
},
|
||||||
|
TimelineEventDetail {
|
||||||
|
label: "City".to_string(),
|
||||||
|
value: roaster
|
||||||
|
.city
|
||||||
|
.as_ref()
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "—".to_string()),
|
||||||
|
},
|
||||||
|
TimelineEventDetail {
|
||||||
|
label: "Homepage".to_string(),
|
||||||
|
value: homepage_value,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let details_json = serde_json::to_string(&details).map_err(|err| {
|
||||||
|
RepositoryError::unexpected(format!("failed to encode timeline event details: {err}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
sqlx::query("INSERT INTO timeline_events (id, entity_type, entity_id, occurred_at, title, details_json, tasting_notes_json) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
||||||
|
.bind(generate_id())
|
||||||
|
.bind("roaster")
|
||||||
|
.bind(&roaster.id)
|
||||||
|
.bind(roaster.created_at)
|
||||||
|
.bind(&roaster.name)
|
||||||
|
.bind(details_json)
|
||||||
|
.bind(Option::<String>::None)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
tx.commit()
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
Ok(roaster)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(&self, id: String) -> Result<Roaster, RepositoryError> {
|
||||||
|
let record = query_as::<_, RoasterRecord>(
|
||||||
|
"SELECT id, name, country, city, homepage, notes, created_at FROM roasters WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
match record {
|
||||||
|
Some(record) => Self::to_domain(record),
|
||||||
|
None => Err(RepositoryError::NotFound),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(
|
||||||
|
&self,
|
||||||
|
request: &ListRequest<RoasterSortKey>,
|
||||||
|
) -> Result<Page<Roaster>, RepositoryError> {
|
||||||
|
let order_clause = roaster_order_clause(request);
|
||||||
|
|
||||||
|
match request.page_size() {
|
||||||
|
PageSize::All => {
|
||||||
|
let query = format!(
|
||||||
|
"SELECT id, name, country, city, homepage, notes, created_at FROM roasters ORDER BY {}",
|
||||||
|
order_clause
|
||||||
|
);
|
||||||
|
|
||||||
|
let records = query_as::<_, RoasterRecord>(&query)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
let items = records
|
||||||
|
.into_iter()
|
||||||
|
.map(Self::to_domain)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
let total = items.len() as u64;
|
||||||
|
let page_size = total.min(u64::from(u32::MAX)) as u32;
|
||||||
|
Ok(Page::new(items, 1, page_size.max(1), total, true))
|
||||||
|
}
|
||||||
|
PageSize::Limited(page_size) => {
|
||||||
|
let page_size_i64 = page_size as i64;
|
||||||
|
let mut page = request.page();
|
||||||
|
let offset = ((page - 1) as i64).saturating_mul(page_size_i64);
|
||||||
|
|
||||||
|
let query = format!(
|
||||||
|
"SELECT id, name, country, city, homepage, notes, created_at FROM roasters ORDER BY {} LIMIT ? OFFSET ?",
|
||||||
|
order_clause
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut records = query_as::<_, RoasterRecord>(&query)
|
||||||
|
.bind(page_size_i64)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
let total: i64 = query_scalar::<_, i64>("SELECT COUNT(*) FROM roasters")
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
if page > 1 && records.is_empty() && total > 0 {
|
||||||
|
let last_page = ((total + page_size_i64 - 1) / page_size_i64) as u32;
|
||||||
|
page = last_page.max(1);
|
||||||
|
let offset = ((page - 1) as i64).saturating_mul(page_size_i64);
|
||||||
|
records = query_as::<_, RoasterRecord>(&query)
|
||||||
|
.bind(page_size_i64)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let items = records
|
||||||
|
.into_iter()
|
||||||
|
.map(Self::to_domain)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
Ok(Page::new(items, page, page_size, total as u64, false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update(&self, id: String, changes: UpdateRoaster) -> Result<Roaster, RepositoryError> {
|
||||||
|
let mut builder = QueryBuilder::new("UPDATE roasters SET ");
|
||||||
|
let mut first = true;
|
||||||
|
|
||||||
|
if let Some(name) = changes.name {
|
||||||
|
if !first {
|
||||||
|
builder.push(", ");
|
||||||
|
}
|
||||||
|
first = false;
|
||||||
|
builder.push("name = ");
|
||||||
|
builder.push_bind(name);
|
||||||
|
}
|
||||||
|
if let Some(country) = changes.country {
|
||||||
|
if !first {
|
||||||
|
builder.push(", ");
|
||||||
|
}
|
||||||
|
first = false;
|
||||||
|
builder.push("country = ");
|
||||||
|
builder.push_bind(country);
|
||||||
|
}
|
||||||
|
if let Some(city) = changes.city {
|
||||||
|
if !first {
|
||||||
|
builder.push(", ");
|
||||||
|
}
|
||||||
|
first = false;
|
||||||
|
builder.push("city = ");
|
||||||
|
builder.push_bind(city);
|
||||||
|
}
|
||||||
|
if let Some(homepage) = changes.homepage {
|
||||||
|
if !first {
|
||||||
|
builder.push(", ");
|
||||||
|
}
|
||||||
|
first = false;
|
||||||
|
builder.push("homepage = ");
|
||||||
|
builder.push_bind(homepage);
|
||||||
|
}
|
||||||
|
if let Some(notes) = changes.notes {
|
||||||
|
if !first {
|
||||||
|
builder.push(", ");
|
||||||
|
}
|
||||||
|
first = false;
|
||||||
|
builder.push("notes = ");
|
||||||
|
builder.push_bind(notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if first {
|
||||||
|
return Err(RepositoryError::unexpected(
|
||||||
|
"No fields provided for update".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.push(" WHERE id = ");
|
||||||
|
builder.push_bind(&id);
|
||||||
|
|
||||||
|
let result = builder
|
||||||
|
.build()
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(RepositoryError::NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.get(id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, id: String) -> Result<(), RepositoryError> {
|
||||||
|
let result = sqlx::query("DELETE FROM roasters WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(RepositoryError::NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
struct RoasterRecord {
|
||||||
|
id: DbId,
|
||||||
|
name: String,
|
||||||
|
country: String,
|
||||||
|
city: Option<String>,
|
||||||
|
homepage: Option<String>,
|
||||||
|
notes: Option<String>,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
479
src/infrastructure/repositories/roasts.rs
Normal file
479
src/infrastructure/repositories/roasts.rs
Normal file
|
|
@ -0,0 +1,479 @@
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use sqlx::{Error as SqlxError, QueryBuilder, query, query_as, query_scalar};
|
||||||
|
|
||||||
|
use crate::domain::RepositoryError;
|
||||||
|
use crate::domain::ids::generate_id;
|
||||||
|
use crate::domain::listing::{ListRequest, Page, PageSize, SortDirection};
|
||||||
|
use crate::domain::repositories::RoastRepository;
|
||||||
|
use crate::domain::roasts::{Roast, RoastSortKey, RoastWithRoaster, UpdateRoast};
|
||||||
|
use crate::domain::timeline::TimelineEventDetail;
|
||||||
|
use crate::infrastructure::database::DatabasePool;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SqlRoastRepository {
|
||||||
|
pool: DatabasePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqlRoastRepository {
|
||||||
|
pub fn new(pool: DatabasePool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_domain(record: RoastRecord) -> Result<Roast, RepositoryError> {
|
||||||
|
let RoastRecord {
|
||||||
|
id,
|
||||||
|
roaster_id,
|
||||||
|
name,
|
||||||
|
origin,
|
||||||
|
region,
|
||||||
|
producer,
|
||||||
|
process,
|
||||||
|
tasting_notes,
|
||||||
|
created_at,
|
||||||
|
} = record;
|
||||||
|
|
||||||
|
let tasting_notes = match tasting_notes {
|
||||||
|
Some(raw) if !raw.is_empty() => serde_json::from_str(&raw).map_err(|err| {
|
||||||
|
RepositoryError::unexpected(format!("failed to decode tasting notes: {err}"))
|
||||||
|
})?,
|
||||||
|
_ => Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Roast {
|
||||||
|
id,
|
||||||
|
roaster_id,
|
||||||
|
name,
|
||||||
|
origin,
|
||||||
|
region,
|
||||||
|
producer,
|
||||||
|
tasting_notes,
|
||||||
|
process,
|
||||||
|
created_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_with_roaster(
|
||||||
|
record: RoastWithRoasterRecord,
|
||||||
|
) -> Result<RoastWithRoaster, RepositoryError> {
|
||||||
|
let RoastWithRoasterRecord {
|
||||||
|
id,
|
||||||
|
roaster_id,
|
||||||
|
name,
|
||||||
|
origin,
|
||||||
|
region,
|
||||||
|
producer,
|
||||||
|
process,
|
||||||
|
tasting_notes,
|
||||||
|
created_at,
|
||||||
|
roaster_name,
|
||||||
|
} = record;
|
||||||
|
|
||||||
|
let roast = Self::to_domain(RoastRecord {
|
||||||
|
id,
|
||||||
|
roaster_id,
|
||||||
|
name,
|
||||||
|
origin,
|
||||||
|
region,
|
||||||
|
producer,
|
||||||
|
process,
|
||||||
|
tasting_notes,
|
||||||
|
created_at,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(RoastWithRoaster {
|
||||||
|
roast,
|
||||||
|
roaster_name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_record(&self, id: &str) -> Result<Roast, RepositoryError> {
|
||||||
|
let record = query_as::<_, RoastRecord>(
|
||||||
|
"SELECT id, roaster_id, name, origin, region, producer, process, tasting_notes, created_at FROM roasts WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
let Some(record) = record else {
|
||||||
|
return Err(RepositoryError::NotFound);
|
||||||
|
};
|
||||||
|
|
||||||
|
Self::to_domain(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_notes(notes: &[String]) -> Result<Option<String>, RepositoryError> {
|
||||||
|
if notes.is_empty() {
|
||||||
|
Ok(None)
|
||||||
|
} else {
|
||||||
|
serde_json::to_string(notes).map(Some).map_err(|err| {
|
||||||
|
RepositoryError::unexpected(format!("failed to encode tasting notes: {err}"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn roast_order_clause(request: &ListRequest<RoastSortKey>) -> String {
|
||||||
|
let dir_sql = match request.sort_direction() {
|
||||||
|
SortDirection::Asc => "ASC",
|
||||||
|
SortDirection::Desc => "DESC",
|
||||||
|
};
|
||||||
|
|
||||||
|
match request.sort_key() {
|
||||||
|
RoastSortKey::CreatedAt => format!("r.created_at {dir_sql}, LOWER(r.name) ASC"),
|
||||||
|
RoastSortKey::Name => format!("LOWER(r.name) {dir_sql}, r.created_at DESC"),
|
||||||
|
RoastSortKey::Roaster => format!("LOWER(ro.name) {dir_sql}, r.created_at DESC"),
|
||||||
|
RoastSortKey::Origin => {
|
||||||
|
format!("LOWER(COALESCE(r.origin, '')) {dir_sql}, r.created_at DESC")
|
||||||
|
}
|
||||||
|
RoastSortKey::Producer => {
|
||||||
|
format!("LOWER(COALESCE(r.producer, '')) {dir_sql}, r.created_at DESC")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl RoastRepository for SqlRoastRepository {
|
||||||
|
async fn insert(&self, roast: Roast) -> Result<Roast, RepositoryError> {
|
||||||
|
let mut tx = self
|
||||||
|
.pool
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
let notes = Self::encode_notes(&roast.tasting_notes)?;
|
||||||
|
|
||||||
|
query(
|
||||||
|
"INSERT INTO roasts (id, roaster_id, name, origin, region, producer, process, tasting_notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&roast.id)
|
||||||
|
.bind(&roast.roaster_id)
|
||||||
|
.bind(&roast.name)
|
||||||
|
.bind(&roast.origin)
|
||||||
|
.bind(&roast.region)
|
||||||
|
.bind(&roast.producer)
|
||||||
|
.bind(&roast.process)
|
||||||
|
.bind(notes.as_deref())
|
||||||
|
.bind(roast.created_at)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| map_insert_error(err, "unknown roaster reference"))?;
|
||||||
|
|
||||||
|
let roaster_name: Option<String> =
|
||||||
|
sqlx::query_scalar("SELECT name FROM roasters WHERE id = ?")
|
||||||
|
.bind(&roast.roaster_id)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
let roaster_label = roaster_name.unwrap_or_else(|| "Unknown roaster".to_string());
|
||||||
|
|
||||||
|
let details = vec![
|
||||||
|
TimelineEventDetail {
|
||||||
|
label: "Roaster".to_string(),
|
||||||
|
value: roaster_label.clone(),
|
||||||
|
},
|
||||||
|
TimelineEventDetail {
|
||||||
|
label: "Origin".to_string(),
|
||||||
|
value: roast.origin.clone().unwrap_or_else(|| "—".to_string()),
|
||||||
|
},
|
||||||
|
TimelineEventDetail {
|
||||||
|
label: "Region".to_string(),
|
||||||
|
value: roast.region.clone().unwrap_or_else(|| "—".to_string()),
|
||||||
|
},
|
||||||
|
TimelineEventDetail {
|
||||||
|
label: "Producer".to_string(),
|
||||||
|
value: roast.producer.clone().unwrap_or_else(|| "—".to_string()),
|
||||||
|
},
|
||||||
|
TimelineEventDetail {
|
||||||
|
label: "Process".to_string(),
|
||||||
|
value: roast.process.clone().unwrap_or_else(|| "—".to_string()),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let details_json = serde_json::to_string(&details).map_err(|err| {
|
||||||
|
RepositoryError::unexpected(format!("failed to encode timeline event details: {err}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let tasting_notes_json = if roast.tasting_notes.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(serde_json::to_string(&roast.tasting_notes).map_err(|err| {
|
||||||
|
RepositoryError::unexpected(format!(
|
||||||
|
"failed to encode timeline event tasting notes: {err}"
|
||||||
|
))
|
||||||
|
})?)
|
||||||
|
};
|
||||||
|
|
||||||
|
sqlx::query("INSERT INTO timeline_events (id, entity_type, entity_id, occurred_at, title, details_json, tasting_notes_json) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
||||||
|
.bind(generate_id())
|
||||||
|
.bind("roast")
|
||||||
|
.bind(&roast.id)
|
||||||
|
.bind(roast.created_at)
|
||||||
|
.bind(&roast.name)
|
||||||
|
.bind(details_json)
|
||||||
|
.bind(tasting_notes_json)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
tx.commit()
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
Ok(roast)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(&self, id: String) -> Result<Roast, RepositoryError> {
|
||||||
|
self.get_record(&id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(
|
||||||
|
&self,
|
||||||
|
request: &ListRequest<RoastSortKey>,
|
||||||
|
) -> Result<Page<RoastWithRoaster>, RepositoryError> {
|
||||||
|
let order_clause = roast_order_clause(request);
|
||||||
|
|
||||||
|
match request.page_size() {
|
||||||
|
PageSize::All => {
|
||||||
|
let query = format!(
|
||||||
|
"SELECT r.id, r.roaster_id, r.name, r.origin, r.region, r.producer, r.process, r.tasting_notes, r.created_at, ro.name AS roaster_name \
|
||||||
|
FROM roasts r \
|
||||||
|
JOIN roasters ro ON ro.id = r.roaster_id \
|
||||||
|
ORDER BY {}",
|
||||||
|
order_clause
|
||||||
|
);
|
||||||
|
|
||||||
|
let records = query_as::<_, RoastWithRoasterRecord>(&query)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
let items = records
|
||||||
|
.into_iter()
|
||||||
|
.map(Self::to_with_roaster)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
let total = items.len() as u64;
|
||||||
|
let page_size = total.min(u64::from(u32::MAX)) as u32;
|
||||||
|
Ok(Page::new(items, 1, page_size.max(1), total, true))
|
||||||
|
}
|
||||||
|
PageSize::Limited(page_size) => {
|
||||||
|
let page_size_i64 = page_size as i64;
|
||||||
|
let mut page = request.page();
|
||||||
|
let offset = ((page - 1) as i64).saturating_mul(page_size_i64);
|
||||||
|
|
||||||
|
let query = format!(
|
||||||
|
"SELECT r.id, r.roaster_id, r.name, r.origin, r.region, r.producer, r.process, r.tasting_notes, r.created_at, ro.name AS roaster_name \
|
||||||
|
FROM roasts r \
|
||||||
|
JOIN roasters ro ON ro.id = r.roaster_id \
|
||||||
|
ORDER BY {} \
|
||||||
|
LIMIT ? OFFSET ?",
|
||||||
|
order_clause
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut records = query_as::<_, RoastWithRoasterRecord>(&query)
|
||||||
|
.bind(page_size_i64)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
let total: i64 = query_scalar::<_, i64>("SELECT COUNT(*) FROM roasts")
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
if page > 1 && records.is_empty() && total > 0 {
|
||||||
|
let last_page = ((total + page_size_i64 - 1) / page_size_i64) as u32;
|
||||||
|
page = last_page.max(1);
|
||||||
|
let offset = ((page - 1) as i64).saturating_mul(page_size_i64);
|
||||||
|
records = query_as::<_, RoastWithRoasterRecord>(&query)
|
||||||
|
.bind(page_size_i64)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let items = records
|
||||||
|
.into_iter()
|
||||||
|
.map(Self::to_with_roaster)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
Ok(Page::new(items, page, page_size, total as u64, false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_by_roaster(
|
||||||
|
&self,
|
||||||
|
roaster_id: String,
|
||||||
|
) -> Result<Vec<RoastWithRoaster>, RepositoryError> {
|
||||||
|
let records = query_as::<_, RoastWithRoasterRecord>(
|
||||||
|
"SELECT r.id, r.roaster_id, r.name, r.origin, r.region, r.producer, r.process, r.tasting_notes, r.created_at, ro.name AS roaster_name \
|
||||||
|
FROM roasts r \
|
||||||
|
JOIN roasters ro ON ro.id = r.roaster_id \
|
||||||
|
WHERE r.roaster_id = ? \
|
||||||
|
ORDER BY r.created_at DESC",
|
||||||
|
)
|
||||||
|
.bind(roaster_id)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
records.into_iter().map(Self::to_with_roaster).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update(&self, id: String, changes: UpdateRoast) -> Result<Roast, RepositoryError> {
|
||||||
|
let mut tx = self
|
||||||
|
.pool
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
let UpdateRoast {
|
||||||
|
roaster_id,
|
||||||
|
name,
|
||||||
|
origin,
|
||||||
|
region,
|
||||||
|
producer,
|
||||||
|
tasting_notes,
|
||||||
|
process,
|
||||||
|
} = changes;
|
||||||
|
|
||||||
|
let mut builder = QueryBuilder::new("UPDATE roasts SET ");
|
||||||
|
let mut updated = false;
|
||||||
|
|
||||||
|
if let Some(roaster_id) = roaster_id {
|
||||||
|
if updated {
|
||||||
|
builder.push(", ");
|
||||||
|
}
|
||||||
|
updated = true;
|
||||||
|
builder.push("roaster_id = ");
|
||||||
|
builder.push_bind(roaster_id);
|
||||||
|
}
|
||||||
|
if let Some(name) = name {
|
||||||
|
if updated {
|
||||||
|
builder.push(", ");
|
||||||
|
}
|
||||||
|
updated = true;
|
||||||
|
builder.push("name = ");
|
||||||
|
builder.push_bind(name);
|
||||||
|
}
|
||||||
|
if let Some(origin) = origin {
|
||||||
|
if updated {
|
||||||
|
builder.push(", ");
|
||||||
|
}
|
||||||
|
updated = true;
|
||||||
|
builder.push("origin = ");
|
||||||
|
builder.push_bind(origin);
|
||||||
|
}
|
||||||
|
if let Some(region) = region {
|
||||||
|
if updated {
|
||||||
|
builder.push(", ");
|
||||||
|
}
|
||||||
|
updated = true;
|
||||||
|
builder.push("region = ");
|
||||||
|
builder.push_bind(region);
|
||||||
|
}
|
||||||
|
if let Some(producer) = producer {
|
||||||
|
if updated {
|
||||||
|
builder.push(", ");
|
||||||
|
}
|
||||||
|
updated = true;
|
||||||
|
builder.push("producer = ");
|
||||||
|
builder.push_bind(producer);
|
||||||
|
}
|
||||||
|
if let Some(process) = process {
|
||||||
|
if updated {
|
||||||
|
builder.push(", ");
|
||||||
|
}
|
||||||
|
updated = true;
|
||||||
|
builder.push("process = ");
|
||||||
|
builder.push_bind(process);
|
||||||
|
}
|
||||||
|
if let Some(tasting_notes) = tasting_notes {
|
||||||
|
let notes = Self::encode_notes(&tasting_notes)?;
|
||||||
|
if updated {
|
||||||
|
builder.push(", ");
|
||||||
|
}
|
||||||
|
updated = true;
|
||||||
|
builder.push("tasting_notes = ");
|
||||||
|
builder.push_bind(notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if updated {
|
||||||
|
builder.push(" WHERE id = ");
|
||||||
|
builder.push_bind(&id);
|
||||||
|
|
||||||
|
let result = builder
|
||||||
|
.build()
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(RepositoryError::NotFound);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit()
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
self.get_record(&id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, id: String) -> Result<(), RepositoryError> {
|
||||||
|
let result = query("DELETE FROM roasts WHERE id = ?")
|
||||||
|
.bind(&id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(RepositoryError::NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_insert_error(err: SqlxError, message: &'static str) -> RepositoryError {
|
||||||
|
if let SqlxError::Database(db_err) = &err {
|
||||||
|
if db_err.code().as_deref() == Some("787") {
|
||||||
|
return RepositoryError::conflict(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RepositoryError::unexpected(err.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct RoastRecord {
|
||||||
|
id: String,
|
||||||
|
roaster_id: String,
|
||||||
|
name: String,
|
||||||
|
origin: Option<String>,
|
||||||
|
region: Option<String>,
|
||||||
|
producer: Option<String>,
|
||||||
|
process: Option<String>,
|
||||||
|
tasting_notes: Option<String>,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct RoastWithRoasterRecord {
|
||||||
|
id: String,
|
||||||
|
roaster_id: String,
|
||||||
|
name: String,
|
||||||
|
origin: Option<String>,
|
||||||
|
region: Option<String>,
|
||||||
|
producer: Option<String>,
|
||||||
|
process: Option<String>,
|
||||||
|
tasting_notes: Option<String>,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
roaster_name: String,
|
||||||
|
}
|
||||||
84
src/infrastructure/repositories/timeline_events.rs
Normal file
84
src/infrastructure/repositories/timeline_events.rs
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde_json::from_str;
|
||||||
|
use sqlx::query_as;
|
||||||
|
|
||||||
|
use crate::domain::RepositoryError;
|
||||||
|
use crate::domain::repositories::TimelineEventRepository;
|
||||||
|
use crate::domain::timeline::{TimelineEvent, TimelineEventDetail};
|
||||||
|
use crate::infrastructure::database::DatabasePool;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SqlTimelineEventRepository {
|
||||||
|
pool: DatabasePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqlTimelineEventRepository {
|
||||||
|
pub fn new(pool: DatabasePool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl TimelineEventRepository for SqlTimelineEventRepository {
|
||||||
|
async fn list_all(&self) -> Result<Vec<TimelineEvent>, RepositoryError> {
|
||||||
|
let records = query_as::<_, TimelineEventRecord>(
|
||||||
|
"SELECT id, entity_type, entity_id, occurred_at, title, details_json, tasting_notes_json FROM timeline_events ORDER BY occurred_at DESC",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
let mut events = Vec::with_capacity(records.len());
|
||||||
|
for record in records {
|
||||||
|
events.push(record.into_domain()?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct TimelineEventRecord {
|
||||||
|
id: String,
|
||||||
|
entity_type: String,
|
||||||
|
entity_id: String,
|
||||||
|
occurred_at: DateTime<Utc>,
|
||||||
|
title: String,
|
||||||
|
details_json: Option<String>,
|
||||||
|
tasting_notes_json: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TimelineEventRecord {
|
||||||
|
fn into_domain(self) -> Result<TimelineEvent, RepositoryError> {
|
||||||
|
let details = match self.details_json {
|
||||||
|
Some(raw) if !raw.is_empty() => {
|
||||||
|
from_str::<Vec<TimelineEventDetail>>(&raw).map_err(|err| {
|
||||||
|
RepositoryError::unexpected(format!(
|
||||||
|
"failed to decode timeline event details: {err}"
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
_ => Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let tasting_notes = match self.tasting_notes_json {
|
||||||
|
Some(raw) if !raw.is_empty() => from_str::<Vec<String>>(&raw).map_err(|err| {
|
||||||
|
RepositoryError::unexpected(format!(
|
||||||
|
"failed to decode timeline event tasting notes: {err}"
|
||||||
|
))
|
||||||
|
})?,
|
||||||
|
_ => Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(TimelineEvent {
|
||||||
|
id: self.id,
|
||||||
|
entity_type: self.entity_type,
|
||||||
|
entity_id: self.entity_id,
|
||||||
|
occurred_at: self.occurred_at,
|
||||||
|
title: self.title,
|
||||||
|
details,
|
||||||
|
tasting_notes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/lib.rs
Normal file
6
src/lib.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
pub mod cli;
|
||||||
|
pub mod client;
|
||||||
|
pub mod domain;
|
||||||
|
pub mod infrastructure;
|
||||||
|
pub mod presentation;
|
||||||
|
pub mod server;
|
||||||
56
src/main.rs
Normal file
56
src/main.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use brewlog::cli::{Cli, Commands, ServeCommand, roasters, roasts};
|
||||||
|
use brewlog::client::BrewlogClient;
|
||||||
|
use brewlog::server::{ServerConfig, serve};
|
||||||
|
use clap::Parser;
|
||||||
|
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
if let Err(err) = init_tracing() {
|
||||||
|
eprintln!("failed to initialize tracing: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Commands::Serve(cmd) => run_server(cmd).await,
|
||||||
|
command => {
|
||||||
|
let client = BrewlogClient::from_base_url(&cli.api_url)?;
|
||||||
|
match command {
|
||||||
|
// Roasters
|
||||||
|
Commands::AddRoaster(cmd) => roasters::add_roaster(&client, cmd).await,
|
||||||
|
Commands::ListRoasters => roasters::list_roasters(&client).await,
|
||||||
|
Commands::GetRoaster(cmd) => roasters::get_roaster(&client, cmd).await,
|
||||||
|
Commands::UpdateRoaster(cmd) => roasters::update_roaster(&client, cmd).await,
|
||||||
|
Commands::DeleteRoaster(cmd) => roasters::delete_roaster(&client, cmd).await,
|
||||||
|
|
||||||
|
// Roasts
|
||||||
|
Commands::AddRoast(cmd) => roasts::add_roast(&client, cmd).await,
|
||||||
|
Commands::ListRoasts(cmd) => roasts::list_roasts(&client, cmd).await,
|
||||||
|
Commands::GetRoast(cmd) => roasts::get_roast(&client, cmd).await,
|
||||||
|
Commands::DeleteRoast(cmd) => roasts::delete_roast(&client, cmd).await,
|
||||||
|
Commands::Serve(_) => unreachable!("serve command handled earlier"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_server(command: ServeCommand) -> Result<()> {
|
||||||
|
let config = ServerConfig {
|
||||||
|
bind_address: command.bind_address,
|
||||||
|
database_url: command.database_url,
|
||||||
|
};
|
||||||
|
|
||||||
|
serve(config).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_tracing() -> anyhow::Result<()> {
|
||||||
|
let env_filter = EnvFilter::try_from_default_env().or_else(|_| EnvFilter::try_new("info"))?;
|
||||||
|
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(env_filter)
|
||||||
|
.with(fmt::layer())
|
||||||
|
.try_init()
|
||||||
|
.map_err(|err| anyhow::anyhow!("failed to initialize tracing: {err}"))
|
||||||
|
}
|
||||||
5
src/presentation/mod.rs
Normal file
5
src/presentation/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
pub mod templates;
|
||||||
|
pub mod views;
|
||||||
|
|
||||||
|
pub use templates::*;
|
||||||
|
pub use views::*;
|
||||||
64
src/presentation/templates.rs
Normal file
64
src/presentation/templates.rs
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
use askama::Template;
|
||||||
|
|
||||||
|
use super::views::{
|
||||||
|
ListNavigator, Paginated, RoastView, RoasterOptionView, RoasterView, TimelineMonthView,
|
||||||
|
};
|
||||||
|
use crate::domain::roasters::RoasterSortKey;
|
||||||
|
use crate::domain::roasts::RoastSortKey;
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "roasters.html")]
|
||||||
|
pub struct RoastersTemplate {
|
||||||
|
pub nav_active: &'static str,
|
||||||
|
pub roasters: Paginated<RoasterView>,
|
||||||
|
pub navigator: ListNavigator<RoasterSortKey>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "partials/roaster_list.html")]
|
||||||
|
pub struct RoasterListTemplate {
|
||||||
|
pub roasters: Paginated<RoasterView>,
|
||||||
|
pub navigator: ListNavigator<RoasterSortKey>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "roaster_detail.html")]
|
||||||
|
pub struct RoasterDetailTemplate {
|
||||||
|
pub nav_active: &'static str,
|
||||||
|
pub roaster: RoasterView,
|
||||||
|
pub roasts: Vec<RoastView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "roasts.html")]
|
||||||
|
pub struct RoastsTemplate {
|
||||||
|
pub nav_active: &'static str,
|
||||||
|
pub roasts: Paginated<RoastView>,
|
||||||
|
pub roaster_options: Vec<RoasterOptionView>,
|
||||||
|
pub navigator: ListNavigator<RoastSortKey>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "roast_detail.html")]
|
||||||
|
pub struct RoastDetailTemplate {
|
||||||
|
pub nav_active: &'static str,
|
||||||
|
pub roast: RoastView,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "partials/roast_list.html")]
|
||||||
|
pub struct RoastListTemplate {
|
||||||
|
pub roasts: Paginated<RoastView>,
|
||||||
|
pub navigator: ListNavigator<RoastSortKey>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "timeline.html")]
|
||||||
|
pub struct TimelineTemplate {
|
||||||
|
pub nav_active: &'static str,
|
||||||
|
pub months: Vec<TimelineMonthView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_template<T: Template>(template: T) -> Result<String, askama::Error> {
|
||||||
|
template.render()
|
||||||
|
}
|
||||||
433
src/presentation/views.rs
Normal file
433
src/presentation/views.rs
Normal file
|
|
@ -0,0 +1,433 @@
|
||||||
|
use crate::domain::listing::{DEFAULT_PAGE_SIZE, ListRequest, Page, PageSize, SortKey};
|
||||||
|
use crate::domain::roasters::Roaster;
|
||||||
|
use crate::domain::roasts::{Roast, RoastWithRoaster};
|
||||||
|
|
||||||
|
pub struct Paginated<T> {
|
||||||
|
pub items: Vec<T>,
|
||||||
|
pub page: u32,
|
||||||
|
pub page_size: u32,
|
||||||
|
pub total: u64,
|
||||||
|
pub showing_all: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Paginated<T> {
|
||||||
|
pub fn new(items: Vec<T>, page: u32, page_size: u32, total: u64, showing_all: bool) -> Self {
|
||||||
|
let page = page.max(1);
|
||||||
|
let page_size = page_size.max(1);
|
||||||
|
Self {
|
||||||
|
items,
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
total,
|
||||||
|
showing_all,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_page<U, MapFn>(page: Page<U>, mut map_item: MapFn) -> Self
|
||||||
|
where
|
||||||
|
MapFn: FnMut(U) -> T,
|
||||||
|
{
|
||||||
|
let items = page.items.into_iter().map(|item| map_item(item)).collect();
|
||||||
|
|
||||||
|
Self::new(
|
||||||
|
items,
|
||||||
|
page.page,
|
||||||
|
page.page_size,
|
||||||
|
page.total,
|
||||||
|
page.showing_all,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn total_pages(&self) -> u32 {
|
||||||
|
if self.total == 0 {
|
||||||
|
1
|
||||||
|
} else if self.showing_all {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
let page_size = self.page_size as u64;
|
||||||
|
((self.total + page_size - 1) / page_size) as u32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_previous(&self) -> bool {
|
||||||
|
!self.showing_all && self.page > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_next(&self) -> bool {
|
||||||
|
!self.showing_all && self.page < self.total_pages()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn previous_page(&self) -> Option<u32> {
|
||||||
|
if self.has_previous() {
|
||||||
|
Some(self.page - 1)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_page(&self) -> Option<u32> {
|
||||||
|
if self.has_next() {
|
||||||
|
Some(self.page + 1)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_index(&self) -> u64 {
|
||||||
|
if self.total == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
((self.page - 1) as u64) * self.page_size as u64 + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn end_index(&self) -> u64 {
|
||||||
|
if self.total == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
self.start_index() + self.items.len() as u64 - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_showing_all(&self) -> bool {
|
||||||
|
self.showing_all
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_page_size(&self, value: u32) -> bool {
|
||||||
|
!self.showing_all && self.page_size == value
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn page_size_query_value(&self) -> String {
|
||||||
|
if self.showing_all {
|
||||||
|
"all".to_string()
|
||||||
|
} else {
|
||||||
|
self.page_size.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct ListNavigator<K: SortKey> {
|
||||||
|
base_path: &'static str,
|
||||||
|
fragment_path: &'static str,
|
||||||
|
request: ListRequest<K>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<K: SortKey> ListNavigator<K> {
|
||||||
|
pub fn new(
|
||||||
|
base_path: &'static str,
|
||||||
|
fragment_path: &'static str,
|
||||||
|
request: ListRequest<K>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
base_path,
|
||||||
|
fragment_path,
|
||||||
|
request,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn request(&self) -> ListRequest<K> {
|
||||||
|
self.request
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sort_key(&self) -> &'static str {
|
||||||
|
self.request.sort_key().query_value()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sort_direction(&self) -> &'static str {
|
||||||
|
self.request.sort_direction().as_str()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn page(&self) -> u32 {
|
||||||
|
self.request.page()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn page_size_value(&self) -> String {
|
||||||
|
self.request.page_size().to_query_value()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_showing_all(&self) -> bool {
|
||||||
|
self.request.page_size().is_all()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn page_href(&self, page: u32) -> String {
|
||||||
|
self.build_href(self.base_path, self.request.with_page(page))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fragment_page_href(&self, page: u32) -> String {
|
||||||
|
self.build_href(self.fragment_path, self.request.with_page(page))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rows_href(&self, value: &str) -> String {
|
||||||
|
self.build_href(self.base_path, Self::request_for_rows(self.request, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fragment_rows_href(&self, value: &str) -> String {
|
||||||
|
self.build_href(
|
||||||
|
self.fragment_path,
|
||||||
|
Self::request_for_rows(self.request, value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sort_href(&self, key: &str) -> String {
|
||||||
|
self.build_href(self.base_path, Self::request_for_sort(self.request, key))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fragment_sort_href(&self, key: &str) -> String {
|
||||||
|
self.build_href(
|
||||||
|
self.fragment_path,
|
||||||
|
Self::request_for_sort(self.request, key),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_sorted_by(&self, key: &str) -> bool {
|
||||||
|
K::from_query(key)
|
||||||
|
.map(|candidate| candidate == self.request.sort_key())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_sort_dir(&self, key: &str) -> &'static str {
|
||||||
|
let sort_key = K::from_query(key).unwrap_or_else(K::default);
|
||||||
|
let direction = if sort_key == self.request.sort_key() {
|
||||||
|
self.request.sort_direction().opposite()
|
||||||
|
} else {
|
||||||
|
sort_key.default_direction()
|
||||||
|
};
|
||||||
|
direction.as_str()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn query(&self) -> String {
|
||||||
|
Self::query_string(self.request)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn query_for_page(&self, page: u32) -> String {
|
||||||
|
Self::query_string(self.request.with_page(page))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn query_for_rows(&self, value: &str) -> String {
|
||||||
|
Self::query_string(Self::request_for_rows(self.request, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn query_for_sort(&self, key: &str) -> String {
|
||||||
|
Self::query_string(Self::request_for_sort(self.request, key))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_href(&self, path: &str, request: ListRequest<K>) -> String {
|
||||||
|
if let Some((base, fragment)) = path.split_once('#') {
|
||||||
|
format!("{}?{}#{}", base, Self::query_string(request), fragment)
|
||||||
|
} else {
|
||||||
|
format!("{}?{}", path, Self::query_string(request))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_for_rows(request: ListRequest<K>, value: &str) -> ListRequest<K> {
|
||||||
|
let page_size = page_size_from_text(value);
|
||||||
|
request.with_page(1).with_page_size(page_size)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_for_sort(request: ListRequest<K>, key: &str) -> ListRequest<K> {
|
||||||
|
let sort_key = K::from_query(key).unwrap_or_else(K::default);
|
||||||
|
request.with_page(1).with_sort(sort_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_string(request: ListRequest<K>) -> String {
|
||||||
|
format!(
|
||||||
|
"page={}&page_size={}&sort={}&dir={}",
|
||||||
|
request.page(),
|
||||||
|
request.page_size().to_query_value(),
|
||||||
|
request.sort_key().query_value(),
|
||||||
|
request.sort_direction().as_str()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn page_size_from_text(value: &str) -> PageSize {
|
||||||
|
if value.eq_ignore_ascii_case("all") {
|
||||||
|
PageSize::All
|
||||||
|
} else if let Ok(parsed) = value.parse::<u32>() {
|
||||||
|
PageSize::limited(parsed)
|
||||||
|
} else {
|
||||||
|
PageSize::limited(DEFAULT_PAGE_SIZE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RoasterOptionView {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Roaster> for RoasterOptionView {
|
||||||
|
fn from(roaster: Roaster) -> Self {
|
||||||
|
Self {
|
||||||
|
id: roaster.id,
|
||||||
|
name: roaster.name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&Roaster> for RoasterOptionView {
|
||||||
|
fn from(roaster: &Roaster) -> Self {
|
||||||
|
Self {
|
||||||
|
id: roaster.id.clone(),
|
||||||
|
name: roaster.name.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RoasterView {
|
||||||
|
pub id: String,
|
||||||
|
pub detail_path: String,
|
||||||
|
pub name: String,
|
||||||
|
pub country: String,
|
||||||
|
pub city: String,
|
||||||
|
pub has_homepage: bool,
|
||||||
|
pub homepage_url: String,
|
||||||
|
pub homepage_label: String,
|
||||||
|
pub notes: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub created_at_sort_key: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Roaster> for RoasterView {
|
||||||
|
fn from(roaster: Roaster) -> Self {
|
||||||
|
let Roaster {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
country,
|
||||||
|
city,
|
||||||
|
homepage,
|
||||||
|
notes,
|
||||||
|
created_at,
|
||||||
|
} = roaster;
|
||||||
|
|
||||||
|
let homepage = homepage.unwrap_or_default();
|
||||||
|
let has_homepage = !homepage.is_empty();
|
||||||
|
let detail_path = format!("/roasters/{id}");
|
||||||
|
|
||||||
|
let created_at_sort_key = created_at.timestamp();
|
||||||
|
let created_at_label = created_at.format("%Y-%m-%d").to_string();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
detail_path,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
country,
|
||||||
|
city: city.unwrap_or_else(|| "—".to_string()),
|
||||||
|
has_homepage,
|
||||||
|
homepage_url: homepage.clone(),
|
||||||
|
homepage_label: homepage,
|
||||||
|
notes: notes.unwrap_or_else(|| "This roaster has no notes yet.".to_string()),
|
||||||
|
created_at: created_at_label,
|
||||||
|
created_at_sort_key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RoastView {
|
||||||
|
pub id: String,
|
||||||
|
pub full_id: String,
|
||||||
|
pub detail_path: String,
|
||||||
|
pub name: String,
|
||||||
|
pub roaster_label: String,
|
||||||
|
pub origin: String,
|
||||||
|
pub region: String,
|
||||||
|
pub producer: String,
|
||||||
|
pub process: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub created_at_sort_key: i64,
|
||||||
|
pub tasting_notes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RoastView {
|
||||||
|
pub fn from_domain(roast: Roast, roaster_name: &str) -> Self {
|
||||||
|
Self::from_parts(roast, roaster_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_list_item(item: RoastWithRoaster) -> Self {
|
||||||
|
let RoastWithRoaster {
|
||||||
|
roast,
|
||||||
|
roaster_name,
|
||||||
|
} = item;
|
||||||
|
Self::from_parts(roast, &roaster_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_parts(roast: Roast, roaster_name: &str) -> Self {
|
||||||
|
let Roast {
|
||||||
|
id: full_id,
|
||||||
|
roaster_id: _,
|
||||||
|
name,
|
||||||
|
origin,
|
||||||
|
region,
|
||||||
|
producer,
|
||||||
|
tasting_notes,
|
||||||
|
process,
|
||||||
|
created_at,
|
||||||
|
} = roast;
|
||||||
|
|
||||||
|
let id: String = full_id.chars().take(6).collect();
|
||||||
|
let roaster_label = if roaster_name.trim().is_empty() {
|
||||||
|
"Unknown roaster".to_string()
|
||||||
|
} else {
|
||||||
|
roaster_name.to_string()
|
||||||
|
};
|
||||||
|
let origin = origin.unwrap_or_else(|| "—".to_string());
|
||||||
|
let region = region.unwrap_or_else(|| "—".to_string());
|
||||||
|
let producer = producer.unwrap_or_else(|| "—".to_string());
|
||||||
|
let process = process.unwrap_or_else(|| "—".to_string());
|
||||||
|
let created_at_sort_key = created_at.timestamp();
|
||||||
|
let tasting_notes = tasting_notes
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|note| {
|
||||||
|
note.split(|ch| ch == ',' || ch == '\n')
|
||||||
|
.map(|segment| segment.trim().to_string())
|
||||||
|
.filter(|segment| !segment.is_empty())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let created_at = created_at.format("%Y-%m-%d").to_string();
|
||||||
|
let detail_path = format!("/roasts/{full_id}");
|
||||||
|
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
full_id,
|
||||||
|
detail_path,
|
||||||
|
name,
|
||||||
|
roaster_label,
|
||||||
|
origin,
|
||||||
|
region,
|
||||||
|
producer,
|
||||||
|
process,
|
||||||
|
created_at,
|
||||||
|
created_at_sort_key,
|
||||||
|
tasting_notes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TimelineEventDetailView {
|
||||||
|
pub label: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TimelineEventView {
|
||||||
|
pub id: String,
|
||||||
|
pub kind_label: &'static str,
|
||||||
|
pub badge_class: &'static str,
|
||||||
|
pub accent_class: &'static str,
|
||||||
|
pub card_border_class: &'static str,
|
||||||
|
pub title_class: &'static str,
|
||||||
|
pub date_label: String,
|
||||||
|
pub time_label: Option<String>,
|
||||||
|
pub iso_timestamp: String,
|
||||||
|
pub title: String,
|
||||||
|
pub link: String,
|
||||||
|
pub external_link: Option<String>,
|
||||||
|
pub details: Vec<TimelineEventDetailView>,
|
||||||
|
pub tasting_notes: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TimelineMonthView {
|
||||||
|
pub anchor: String,
|
||||||
|
pub heading: String,
|
||||||
|
pub events: Vec<TimelineEventView>,
|
||||||
|
}
|
||||||
86
src/server/errors.rs
Normal file
86
src/server/errors.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
use axum::Json;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
use tracing::error;
|
||||||
|
|
||||||
|
use crate::domain::RepositoryError;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ErrorResponse {
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ErrorResponse {
|
||||||
|
pub fn new<T: ToString>(message: T) -> Self {
|
||||||
|
Self {
|
||||||
|
message: message.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ApiError(AppError);
|
||||||
|
|
||||||
|
impl From<AppError> for ApiError {
|
||||||
|
fn from(value: AppError) -> Self {
|
||||||
|
Self(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for ApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, message) = match self.0 {
|
||||||
|
AppError::Validation(message) => (StatusCode::BAD_REQUEST, message),
|
||||||
|
AppError::NotFound => (StatusCode::NOT_FOUND, "entity not found".to_string()),
|
||||||
|
AppError::Unexpected(message) => {
|
||||||
|
error!(error = %message, "unexpected application error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"unexpected error".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(status, Json(ErrorResponse::new(message))).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn map_app_error(err: AppError) -> StatusCode {
|
||||||
|
match err {
|
||||||
|
AppError::Validation(_) => StatusCode::BAD_REQUEST,
|
||||||
|
AppError::NotFound => StatusCode::NOT_FOUND,
|
||||||
|
AppError::Unexpected(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type AppResult<T> = Result<T, AppError>;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum AppError {
|
||||||
|
#[error("validation failed: {0}")]
|
||||||
|
Validation(String),
|
||||||
|
#[error("entity not found")]
|
||||||
|
NotFound,
|
||||||
|
#[error("unexpected error: {0}")]
|
||||||
|
Unexpected(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppError {
|
||||||
|
pub fn validation<T: ToString>(msg: T) -> Self {
|
||||||
|
Self::Validation(msg.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unexpected<T: ToString>(msg: T) -> Self {
|
||||||
|
Self::Unexpected(msg.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl From<RepositoryError> for AppError {
|
||||||
|
fn from(value: RepositoryError) -> Self {
|
||||||
|
match value {
|
||||||
|
RepositoryError::NotFound => Self::NotFound,
|
||||||
|
RepositoryError::Conflict(msg) => Self::Validation(msg),
|
||||||
|
RepositoryError::Unexpected(msg) => Self::Unexpected(msg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/server/mod.rs
Normal file
6
src/server/mod.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
pub mod errors;
|
||||||
|
pub mod routes;
|
||||||
|
pub mod server;
|
||||||
|
|
||||||
|
pub use routes::app_router;
|
||||||
|
pub use server::{ServerConfig, serve};
|
||||||
73
src/server/routes/mod.rs
Normal file
73
src/server/routes/mod.rs
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
pub mod roasters;
|
||||||
|
pub mod roasts;
|
||||||
|
pub mod support;
|
||||||
|
pub mod timeline;
|
||||||
|
|
||||||
|
use askama::Template;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{Html, IntoResponse, Redirect};
|
||||||
|
use axum::routing::{get, post};
|
||||||
|
use tracing::error;
|
||||||
|
|
||||||
|
use crate::server::server::AppState;
|
||||||
|
|
||||||
|
use crate::presentation::templates::render_template;
|
||||||
|
|
||||||
|
pub fn app_router(state: AppState) -> axum::Router {
|
||||||
|
let api_routes = axum::Router::new()
|
||||||
|
.route(
|
||||||
|
"/roasters",
|
||||||
|
post(roasters::create_roaster).get(roasters::list_roasters),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/roasters/:id",
|
||||||
|
get(roasters::get_roaster)
|
||||||
|
.put(roasters::update_roaster)
|
||||||
|
.delete(roasters::delete_roaster),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/roasts",
|
||||||
|
post(roasts::create_roast).get(roasts::list_roasts),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/roasts/:id",
|
||||||
|
get(roasts::get_roast).delete(roasts::delete_roast),
|
||||||
|
);
|
||||||
|
|
||||||
|
axum::Router::new()
|
||||||
|
.route("/", get(root_redirect))
|
||||||
|
.route("/roasters", get(roasters::roasters_page))
|
||||||
|
.route("/roasters/:id", get(roasters::roaster_page))
|
||||||
|
.route("/roasts", get(roasts::roasts_page))
|
||||||
|
.route("/roasts/:id", get(roasts::roast_page))
|
||||||
|
.route("/timeline", get(timeline::timeline_page))
|
||||||
|
.route("/styles.css", get(styles))
|
||||||
|
.route("/favicon.ico", get(favicon))
|
||||||
|
.nest("/api/v1", api_routes)
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn root_redirect() -> Redirect {
|
||||||
|
Redirect::temporary("/timeline")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn styles() -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
[("content-type", "text/css; charset=utf-8")],
|
||||||
|
include_str!("../../../templates/styles.css"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn favicon() -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
[("content-type", "image/x-icon")],
|
||||||
|
include_bytes!("../../../templates/favicon.ico").as_ref(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn render_html<T: Template>(template: T) -> Result<Html<String>, StatusCode> {
|
||||||
|
render_template(template).map(Html).map_err(|err| {
|
||||||
|
error!(error = %err, "failed to render template");
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})
|
||||||
|
}
|
||||||
221
src/server/routes/roasters.rs
Normal file
221
src/server/routes/roasters.rs
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path, Query, State};
|
||||||
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
|
use axum::response::{Html, IntoResponse, Redirect, Response};
|
||||||
|
|
||||||
|
use crate::domain::listing::{ListRequest, Page, PageSize, SortDirection};
|
||||||
|
use crate::domain::roasters::{NewRoaster, Roaster, RoasterSortKey, UpdateRoaster};
|
||||||
|
use crate::presentation::templates::{
|
||||||
|
RoasterDetailTemplate, RoasterListTemplate, RoastersTemplate,
|
||||||
|
};
|
||||||
|
use crate::presentation::views::{ListNavigator, Paginated, RoastView, RoasterView};
|
||||||
|
use crate::server::errors::{ApiError, AppError, map_app_error};
|
||||||
|
use crate::server::routes::render_html;
|
||||||
|
use crate::server::routes::support::{
|
||||||
|
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request, set_datastar_patch_headers,
|
||||||
|
};
|
||||||
|
use crate::server::server::AppState;
|
||||||
|
|
||||||
|
const ROASTER_PAGE_PATH: &str = "/roasters";
|
||||||
|
const ROASTER_FRAGMENT_PATH: &str = "/roasters#roaster-list";
|
||||||
|
|
||||||
|
fn normalize_request(
|
||||||
|
request: ListRequest<RoasterSortKey>,
|
||||||
|
page: &Page<Roaster>,
|
||||||
|
) -> ListRequest<RoasterSortKey> {
|
||||||
|
let page_size = if page.showing_all {
|
||||||
|
PageSize::All
|
||||||
|
} else {
|
||||||
|
PageSize::limited(page.page_size)
|
||||||
|
};
|
||||||
|
|
||||||
|
ListRequest::new(
|
||||||
|
page.page,
|
||||||
|
page_size,
|
||||||
|
request.sort_key(),
|
||||||
|
request.sort_direction(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn roaster_navigator(request: ListRequest<RoasterSortKey>) -> ListNavigator<RoasterSortKey> {
|
||||||
|
ListNavigator::new(ROASTER_PAGE_PATH, ROASTER_FRAGMENT_PATH, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_roaster_page(
|
||||||
|
state: &AppState,
|
||||||
|
request: ListRequest<RoasterSortKey>,
|
||||||
|
) -> Result<(Paginated<RoasterView>, ListNavigator<RoasterSortKey>), AppError> {
|
||||||
|
let page = state
|
||||||
|
.roaster_repo
|
||||||
|
.list(&request)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
|
let normalized_request = normalize_request(request, &page);
|
||||||
|
let roasters = Paginated::from_page(page, RoasterView::from);
|
||||||
|
let navigator = roaster_navigator(normalized_request);
|
||||||
|
|
||||||
|
Ok((roasters, navigator))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn roasters_page(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Query(query): Query<ListQuery>,
|
||||||
|
) -> Result<Response, StatusCode> {
|
||||||
|
let request = query.into_request::<RoasterSortKey>();
|
||||||
|
|
||||||
|
if is_datastar_request(&headers) {
|
||||||
|
return render_roaster_list_fragment(state, request)
|
||||||
|
.await
|
||||||
|
.map_err(|err| map_app_error(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (roasters, navigator) = load_roaster_page(&state, request)
|
||||||
|
.await
|
||||||
|
.map_err(|err| map_app_error(err))?;
|
||||||
|
|
||||||
|
let template = RoastersTemplate {
|
||||||
|
nav_active: "roasters",
|
||||||
|
roasters,
|
||||||
|
navigator,
|
||||||
|
};
|
||||||
|
|
||||||
|
render_html(template).map(IntoResponse::into_response)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn roaster_page(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<Html<String>, StatusCode> {
|
||||||
|
let roaster = state
|
||||||
|
.roaster_repo
|
||||||
|
.get(id.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|err| map_app_error(AppError::from(err)))?;
|
||||||
|
let roasts = state
|
||||||
|
.roast_repo
|
||||||
|
.list_by_roaster(id)
|
||||||
|
.await
|
||||||
|
.map_err(|err| map_app_error(AppError::from(err)))?;
|
||||||
|
|
||||||
|
let roaster_view = RoasterView::from(roaster);
|
||||||
|
|
||||||
|
let template = RoasterDetailTemplate {
|
||||||
|
nav_active: "roasters",
|
||||||
|
roaster: roaster_view,
|
||||||
|
roasts: roasts.into_iter().map(RoastView::from_list_item).collect(),
|
||||||
|
};
|
||||||
|
|
||||||
|
render_html(template)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn list_roasters(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<Vec<Roaster>>, ApiError> {
|
||||||
|
let roasters = state
|
||||||
|
.roaster_repo
|
||||||
|
.list_all_sorted(RoasterSortKey::Name, SortDirection::Asc)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
Ok(Json(roasters))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn create_roaster(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Query(query): Query<ListQuery>,
|
||||||
|
payload: FlexiblePayload<NewRoaster>,
|
||||||
|
) -> Result<Response, ApiError> {
|
||||||
|
let request = query.into_request::<RoasterSortKey>();
|
||||||
|
let (new_roaster, source) = payload.into_parts();
|
||||||
|
let roaster = new_roaster.normalize().into_roaster();
|
||||||
|
let roaster = state
|
||||||
|
.roaster_repo
|
||||||
|
.insert(roaster)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
|
if is_datastar_request(&headers) {
|
||||||
|
render_roaster_list_fragment(state, request)
|
||||||
|
.await
|
||||||
|
.map_err(ApiError::from)
|
||||||
|
} else if matches!(source, PayloadSource::Form) {
|
||||||
|
let target = roaster_navigator(request).page_href(1);
|
||||||
|
Ok(Redirect::to(&target).into_response())
|
||||||
|
} else {
|
||||||
|
Ok((StatusCode::CREATED, Json(roaster)).into_response())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn get_roaster(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<Json<Roaster>, ApiError> {
|
||||||
|
let roaster = state.roaster_repo.get(id).await.map_err(AppError::from)?;
|
||||||
|
Ok(Json(roaster))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn update_roaster(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Json(payload): Json<UpdateRoaster>,
|
||||||
|
) -> Result<Json<Roaster>, ApiError> {
|
||||||
|
let has_changes = payload.name.is_some()
|
||||||
|
|| payload.country.is_some()
|
||||||
|
|| payload.city.is_some()
|
||||||
|
|| payload.homepage.is_some()
|
||||||
|
|| payload.notes.is_some();
|
||||||
|
|
||||||
|
if !has_changes {
|
||||||
|
return Err(AppError::validation("no changes provided").into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let roaster = state
|
||||||
|
.roaster_repo
|
||||||
|
.update(id, payload)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
Ok(Json(roaster))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn delete_roaster(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Query(query): Query<ListQuery>,
|
||||||
|
) -> Result<Response, ApiError> {
|
||||||
|
let request = query.into_request::<RoasterSortKey>();
|
||||||
|
state
|
||||||
|
.roaster_repo
|
||||||
|
.delete(id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
|
if is_datastar_request(&headers) {
|
||||||
|
render_roaster_list_fragment(state, request)
|
||||||
|
.await
|
||||||
|
.map_err(ApiError::from)
|
||||||
|
} else {
|
||||||
|
Ok(StatusCode::NO_CONTENT.into_response())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn render_roaster_list_fragment(
|
||||||
|
state: AppState,
|
||||||
|
request: ListRequest<RoasterSortKey>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let (roasters, navigator) = load_roaster_page(&state, request).await?;
|
||||||
|
|
||||||
|
let template = RoasterListTemplate {
|
||||||
|
roasters,
|
||||||
|
navigator,
|
||||||
|
};
|
||||||
|
|
||||||
|
let html = crate::presentation::templates::render_template(template)
|
||||||
|
.map_err(|err| AppError::unexpected(format!("failed to render roaster list: {err}")))?;
|
||||||
|
|
||||||
|
let mut response = Html(html).into_response();
|
||||||
|
set_datastar_patch_headers(response.headers_mut(), "#roaster-list");
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
343
src/server/routes/roasts.rs
Normal file
343
src/server/routes/roasts.rs
Normal file
|
|
@ -0,0 +1,343 @@
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path, Query, State};
|
||||||
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
|
use axum::response::{Html, IntoResponse, Redirect, Response};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde::de::{self, Deserializer, SeqAccess, Visitor};
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use crate::domain::listing::{ListRequest, Page, PageSize, SortDirection};
|
||||||
|
use crate::domain::roasters::RoasterSortKey;
|
||||||
|
use crate::domain::roasts::{NewRoast, Roast, RoastSortKey, RoastWithRoaster};
|
||||||
|
use crate::presentation::templates::{RoastDetailTemplate, RoastListTemplate, RoastsTemplate};
|
||||||
|
use crate::presentation::views::{ListNavigator, Paginated, RoastView, RoasterOptionView};
|
||||||
|
use crate::server::errors::{ApiError, AppError, map_app_error};
|
||||||
|
use crate::server::routes::render_html;
|
||||||
|
use crate::server::routes::support::{
|
||||||
|
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request, set_datastar_patch_headers,
|
||||||
|
};
|
||||||
|
use crate::server::server::AppState;
|
||||||
|
|
||||||
|
const ROAST_PAGE_PATH: &str = "/roasts";
|
||||||
|
const ROAST_FRAGMENT_PATH: &str = "/roasts#roast-list";
|
||||||
|
|
||||||
|
fn normalize_request(
|
||||||
|
request: ListRequest<RoastSortKey>,
|
||||||
|
page: &Page<RoastWithRoaster>,
|
||||||
|
) -> ListRequest<RoastSortKey> {
|
||||||
|
let page_size = if page.showing_all {
|
||||||
|
PageSize::All
|
||||||
|
} else {
|
||||||
|
PageSize::limited(page.page_size)
|
||||||
|
};
|
||||||
|
|
||||||
|
ListRequest::new(
|
||||||
|
page.page,
|
||||||
|
page_size,
|
||||||
|
request.sort_key(),
|
||||||
|
request.sort_direction(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn roast_navigator(request: ListRequest<RoastSortKey>) -> ListNavigator<RoastSortKey> {
|
||||||
|
ListNavigator::new(ROAST_PAGE_PATH, ROAST_FRAGMENT_PATH, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_roast_page(
|
||||||
|
state: &AppState,
|
||||||
|
request: ListRequest<RoastSortKey>,
|
||||||
|
) -> Result<(Paginated<RoastView>, ListNavigator<RoastSortKey>), AppError> {
|
||||||
|
let page = state
|
||||||
|
.roast_repo
|
||||||
|
.list(&request)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
|
let normalized_request = normalize_request(request, &page);
|
||||||
|
let roasts = Paginated::from_page(page, RoastView::from_list_item);
|
||||||
|
let navigator = roast_navigator(normalized_request);
|
||||||
|
|
||||||
|
Ok((roasts, navigator))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn roasts_page(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Query(query): Query<ListQuery>,
|
||||||
|
) -> Result<Response, StatusCode> {
|
||||||
|
let request = query.into_request::<RoastSortKey>();
|
||||||
|
|
||||||
|
if is_datastar_request(&headers) {
|
||||||
|
return render_roast_list_fragment(state, request)
|
||||||
|
.await
|
||||||
|
.map_err(|err| map_app_error(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
let roasters = state
|
||||||
|
.roaster_repo
|
||||||
|
.list_all_sorted(RoasterSortKey::Name, SortDirection::Asc)
|
||||||
|
.await
|
||||||
|
.map_err(|err| map_app_error(AppError::from(err)))?;
|
||||||
|
|
||||||
|
let roaster_options = roasters.into_iter().map(RoasterOptionView::from).collect();
|
||||||
|
|
||||||
|
let (roasts, navigator) = load_roast_page(&state, request)
|
||||||
|
.await
|
||||||
|
.map_err(|err| map_app_error(err))?;
|
||||||
|
|
||||||
|
let template = RoastsTemplate {
|
||||||
|
nav_active: "roasts",
|
||||||
|
roasts,
|
||||||
|
roaster_options,
|
||||||
|
navigator,
|
||||||
|
};
|
||||||
|
|
||||||
|
render_html(template).map(IntoResponse::into_response)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn roast_page(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<Html<String>, StatusCode> {
|
||||||
|
let roast = state
|
||||||
|
.roast_repo
|
||||||
|
.get(id.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|err| map_app_error(AppError::from(err)))?;
|
||||||
|
let roaster = state
|
||||||
|
.roaster_repo
|
||||||
|
.get(roast.roaster_id.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|err| map_app_error(AppError::from(err)))?;
|
||||||
|
|
||||||
|
let template = RoastDetailTemplate {
|
||||||
|
nav_active: "roasts",
|
||||||
|
roast: RoastView::from_domain(roast, &roaster.name),
|
||||||
|
};
|
||||||
|
|
||||||
|
render_html(template)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn create_roast(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Query(query): Query<ListQuery>,
|
||||||
|
payload: FlexiblePayload<NewRoastSubmission>,
|
||||||
|
) -> Result<Response, ApiError> {
|
||||||
|
let request = query.into_request::<RoastSortKey>();
|
||||||
|
let (submission, source) = payload.into_parts();
|
||||||
|
let new_roast = submission.into_new_roast().map_err(ApiError::from)?;
|
||||||
|
|
||||||
|
state
|
||||||
|
.roaster_repo
|
||||||
|
.get(new_roast.roaster_id.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|err| ApiError::from(AppError::from(err)))?;
|
||||||
|
|
||||||
|
let roast = new_roast.into_roast();
|
||||||
|
let roast = state
|
||||||
|
.roast_repo
|
||||||
|
.insert(roast)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
|
if is_datastar_request(&headers) {
|
||||||
|
render_roast_list_fragment(state, request)
|
||||||
|
.await
|
||||||
|
.map_err(ApiError::from)
|
||||||
|
} else if matches!(source, PayloadSource::Form) {
|
||||||
|
let target = roast_navigator(request).page_href(1);
|
||||||
|
Ok(Redirect::to(&target).into_response())
|
||||||
|
} else {
|
||||||
|
Ok((StatusCode::CREATED, Json(roast)).into_response())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn list_roasts(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(params): Query<RoastsQuery>,
|
||||||
|
) -> Result<Json<Vec<RoastWithRoaster>>, ApiError> {
|
||||||
|
let roasts = match params.roaster_id {
|
||||||
|
Some(roaster_id) => state
|
||||||
|
.roast_repo
|
||||||
|
.list_by_roaster(roaster_id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)?,
|
||||||
|
None => state.roast_repo.list_all().await.map_err(AppError::from)?,
|
||||||
|
};
|
||||||
|
Ok(Json(roasts))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn get_roast(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<Json<Roast>, ApiError> {
|
||||||
|
let roast = state.roast_repo.get(id).await.map_err(AppError::from)?;
|
||||||
|
Ok(Json(roast))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn delete_roast(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Query(query): Query<ListQuery>,
|
||||||
|
) -> Result<Response, ApiError> {
|
||||||
|
let request = query.into_request::<RoastSortKey>();
|
||||||
|
state.roast_repo.delete(id).await.map_err(AppError::from)?;
|
||||||
|
|
||||||
|
if is_datastar_request(&headers) {
|
||||||
|
render_roast_list_fragment(state, request)
|
||||||
|
.await
|
||||||
|
.map_err(ApiError::from)
|
||||||
|
} else {
|
||||||
|
Ok(StatusCode::NO_CONTENT.into_response())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct RoastsQuery {
|
||||||
|
pub roaster_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(crate) struct NewRoastSubmission {
|
||||||
|
roaster_id: String,
|
||||||
|
name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
origin: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
region: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
producer: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "string_or_vec")]
|
||||||
|
tasting_notes: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
process: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NewRoastSubmission {
|
||||||
|
fn into_new_roast(self) -> Result<NewRoast, AppError> {
|
||||||
|
let roaster_id = self.roaster_id.trim().to_string();
|
||||||
|
if roaster_id.is_empty() {
|
||||||
|
return Err(AppError::validation("roaster is required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = self.name.trim().to_string();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err(AppError::validation("name is required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(NewRoast {
|
||||||
|
roaster_id,
|
||||||
|
name,
|
||||||
|
origin: trim_optional(self.origin),
|
||||||
|
region: trim_optional(self.region),
|
||||||
|
producer: trim_optional(self.producer),
|
||||||
|
tasting_notes: self
|
||||||
|
.tasting_notes
|
||||||
|
.into_iter()
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.collect(),
|
||||||
|
process: trim_optional(self.process),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trim_optional(value: Option<String>) -> Option<String> {
|
||||||
|
value.and_then(|raw| {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed.to_string())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string_or_vec<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
struct StringOrVecVisitor;
|
||||||
|
|
||||||
|
impl<'de> Visitor<'de> for StringOrVecVisitor {
|
||||||
|
type Value = Vec<String>;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
formatter.write_str("a string or sequence of strings")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
|
||||||
|
where
|
||||||
|
E: de::Error,
|
||||||
|
{
|
||||||
|
let split = value
|
||||||
|
.split(|ch| ch == ',' || ch == '\n')
|
||||||
|
.map(|segment| segment.trim().to_string())
|
||||||
|
.filter(|segment| !segment.is_empty())
|
||||||
|
.collect();
|
||||||
|
Ok(split)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
|
||||||
|
where
|
||||||
|
E: de::Error,
|
||||||
|
{
|
||||||
|
self.visit_str(&value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
|
||||||
|
where
|
||||||
|
A: SeqAccess<'de>,
|
||||||
|
{
|
||||||
|
let mut values = Vec::new();
|
||||||
|
while let Some(value) = seq.next_element::<String>()? {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
values.push(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_unit<E>(self) -> Result<Self::Value, E>
|
||||||
|
where
|
||||||
|
E: de::Error,
|
||||||
|
{
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_none<E>(self) -> Result<Self::Value, E>
|
||||||
|
where
|
||||||
|
E: de::Error,
|
||||||
|
{
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_some<D2>(self, deserializer: D2) -> Result<Self::Value, D2::Error>
|
||||||
|
where
|
||||||
|
D2: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
string_or_vec(deserializer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deserializer.deserialize_any(StringOrVecVisitor)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn render_roast_list_fragment(
|
||||||
|
state: AppState,
|
||||||
|
request: ListRequest<RoastSortKey>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let (roasts, navigator) = load_roast_page(&state, request).await?;
|
||||||
|
|
||||||
|
let template = RoastListTemplate { roasts, navigator };
|
||||||
|
|
||||||
|
let html = crate::presentation::templates::render_template(template)
|
||||||
|
.map_err(|err| AppError::unexpected(format!("failed to render roast list: {err}")))?;
|
||||||
|
|
||||||
|
let mut response = Html(html).into_response();
|
||||||
|
set_datastar_patch_headers(response.headers_mut(), "#roast-list");
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
143
src/server/routes/support.rs
Normal file
143
src/server/routes/support.rs
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
use axum::async_trait;
|
||||||
|
use axum::extract::{Form, FromRequest, Json as JsonPayload, Request};
|
||||||
|
use axum::http::{HeaderMap, HeaderValue, header::CONTENT_TYPE};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::domain::listing::{DEFAULT_PAGE_SIZE, ListRequest, PageSize, SortDirection, SortKey};
|
||||||
|
use crate::server::errors::{ApiError, AppError};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum PayloadSource {
|
||||||
|
Json,
|
||||||
|
Form,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FlexiblePayload<T> {
|
||||||
|
inner: T,
|
||||||
|
source: PayloadSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> FlexiblePayload<T> {
|
||||||
|
pub fn into_parts(self) -> (T, PayloadSource) {
|
||||||
|
(self.inner, self.source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
pub(crate) struct ListQuery {
|
||||||
|
page: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
page_size: Option<PageSizeParam>,
|
||||||
|
#[serde(default, rename = "sort")]
|
||||||
|
sort_key: Option<String>,
|
||||||
|
#[serde(default, rename = "dir")]
|
||||||
|
sort_dir: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum PageSizeParam {
|
||||||
|
Number(u32),
|
||||||
|
Text(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListQuery {
|
||||||
|
pub fn into_request<K: SortKey>(self) -> ListRequest<K> {
|
||||||
|
let page = self.page.unwrap_or(1);
|
||||||
|
let page_size = match self.page_size {
|
||||||
|
Some(PageSizeParam::Number(value)) => PageSize::limited(value),
|
||||||
|
Some(PageSizeParam::Text(text)) => page_size_from_text(&text),
|
||||||
|
None => PageSize::limited(DEFAULT_PAGE_SIZE),
|
||||||
|
};
|
||||||
|
|
||||||
|
let sort_key = self
|
||||||
|
.sort_key
|
||||||
|
.as_deref()
|
||||||
|
.and_then(K::from_query)
|
||||||
|
.unwrap_or_else(K::default);
|
||||||
|
|
||||||
|
let sort_direction = self
|
||||||
|
.sort_dir
|
||||||
|
.as_deref()
|
||||||
|
.and_then(parse_direction)
|
||||||
|
.unwrap_or_else(|| sort_key.default_direction());
|
||||||
|
|
||||||
|
ListRequest::new(page, page_size, sort_key, sort_direction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn page_size_from_text(value: &str) -> PageSize {
|
||||||
|
if value.eq_ignore_ascii_case("all") {
|
||||||
|
PageSize::All
|
||||||
|
} else if let Ok(parsed) = value.parse::<u32>() {
|
||||||
|
PageSize::limited(parsed)
|
||||||
|
} else {
|
||||||
|
PageSize::limited(DEFAULT_PAGE_SIZE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_direction(value: &str) -> Option<SortDirection> {
|
||||||
|
match value.to_ascii_lowercase().as_str() {
|
||||||
|
"asc" => Some(SortDirection::Asc),
|
||||||
|
"desc" => Some(SortDirection::Desc),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<S, T> FromRequest<S> for FlexiblePayload<T>
|
||||||
|
where
|
||||||
|
S: Send + Sync,
|
||||||
|
T: Send + 'static,
|
||||||
|
JsonPayload<T>: FromRequest<S>,
|
||||||
|
Form<T>: FromRequest<S>,
|
||||||
|
{
|
||||||
|
type Rejection = ApiError;
|
||||||
|
|
||||||
|
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
let content_type = req
|
||||||
|
.headers()
|
||||||
|
.get(CONTENT_TYPE)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
|
||||||
|
if content_type.starts_with("application/json") {
|
||||||
|
let JsonPayload(payload) = JsonPayload::<T>::from_request(req, state)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::from(AppError::validation("invalid JSON payload")))?;
|
||||||
|
|
||||||
|
return Ok(Self {
|
||||||
|
inner: payload,
|
||||||
|
source: PayloadSource::Json,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if content_type.is_empty() || content_type.starts_with("application/x-www-form-urlencoded")
|
||||||
|
{
|
||||||
|
let Form(payload) = Form::<T>::from_request(req, state)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::from(AppError::validation("invalid form payload")))?;
|
||||||
|
|
||||||
|
return Ok(Self {
|
||||||
|
inner: payload,
|
||||||
|
source: PayloadSource::Form,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(AppError::validation("unsupported content type").into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_datastar_request(headers: &HeaderMap) -> bool {
|
||||||
|
headers
|
||||||
|
.get("datastar-request")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.map(|value| value.eq_ignore_ascii_case("true"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_datastar_patch_headers(headers: &mut HeaderMap, selector: &'static str) {
|
||||||
|
let _ = headers.insert("datastar-selector", HeaderValue::from_static(selector));
|
||||||
|
let _ = headers.insert("datastar-mode", HeaderValue::from_static("replace"));
|
||||||
|
}
|
||||||
118
src/server/routes/timeline.rs
Normal file
118
src/server/routes/timeline.rs
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::Html;
|
||||||
|
|
||||||
|
use crate::presentation::templates::TimelineTemplate;
|
||||||
|
use crate::presentation::views::{TimelineEventDetailView, TimelineEventView, TimelineMonthView};
|
||||||
|
use crate::server::errors::{AppError, map_app_error};
|
||||||
|
use crate::server::routes::render_html;
|
||||||
|
use crate::server::server::AppState;
|
||||||
|
|
||||||
|
pub(crate) async fn timeline_page(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Html<String>, StatusCode> {
|
||||||
|
let events = state
|
||||||
|
.timeline_repo
|
||||||
|
.list_all()
|
||||||
|
.await
|
||||||
|
.map_err(|err| map_app_error(AppError::from(err)))?;
|
||||||
|
|
||||||
|
let mut months: Vec<TimelineMonthView> = Vec::new();
|
||||||
|
|
||||||
|
for event in events {
|
||||||
|
let occurred_at = event.occurred_at;
|
||||||
|
let anchor = occurred_at.format("%Y-%m").to_string();
|
||||||
|
let heading = occurred_at.format("%B %Y").to_string();
|
||||||
|
|
||||||
|
let entity_id = event.entity_id.clone();
|
||||||
|
let kind_label = match event.entity_type.as_str() {
|
||||||
|
"roaster" => "Roaster Added",
|
||||||
|
"roast" => "Roast Added",
|
||||||
|
_ => "Event",
|
||||||
|
};
|
||||||
|
let link = match event.entity_type.as_str() {
|
||||||
|
"roaster" => format!("/roasters/{entity_id}"),
|
||||||
|
"roast" => format!("/roasts/{entity_id}"),
|
||||||
|
_ => String::from("#"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut details: Vec<TimelineEventDetailView> = event
|
||||||
|
.details
|
||||||
|
.into_iter()
|
||||||
|
.map(|detail| TimelineEventDetailView {
|
||||||
|
label: detail.label,
|
||||||
|
value: detail.value,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let external_link = if let Some(index) = details
|
||||||
|
.iter()
|
||||||
|
.position(|detail| detail.label.eq_ignore_ascii_case("homepage"))
|
||||||
|
{
|
||||||
|
let value = details.remove(index).value;
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.is_empty() || trimmed == "—" {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed.to_string())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let tasting_notes = if event.entity_type == "roast" {
|
||||||
|
let notes = event
|
||||||
|
.tasting_notes
|
||||||
|
.clone()
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|note| {
|
||||||
|
note.split(|ch| ch == ',' || ch == '\n')
|
||||||
|
.map(|segment| segment.trim())
|
||||||
|
.filter(|segment| !segment.is_empty())
|
||||||
|
.map(|segment| segment.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
Some(notes)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let view = TimelineEventView {
|
||||||
|
id: event.id,
|
||||||
|
kind_label,
|
||||||
|
badge_class: "bg-amber-200 text-amber-800",
|
||||||
|
accent_class: "bg-amber-600",
|
||||||
|
card_border_class: "border-amber-200 bg-amber-50/80",
|
||||||
|
title_class: "text-amber-800",
|
||||||
|
date_label: occurred_at.format("%B %d, %Y").to_string(),
|
||||||
|
time_label: Some(occurred_at.format("%H:%M UTC").to_string()),
|
||||||
|
iso_timestamp: occurred_at.to_rfc3339(),
|
||||||
|
title: event.title,
|
||||||
|
link,
|
||||||
|
external_link,
|
||||||
|
details,
|
||||||
|
tasting_notes,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(last) = months.last_mut() {
|
||||||
|
if last.anchor == anchor {
|
||||||
|
last.events.push(view);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
months.push(TimelineMonthView {
|
||||||
|
anchor,
|
||||||
|
heading,
|
||||||
|
events: vec![view],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let template = TimelineTemplate {
|
||||||
|
nav_active: "timeline",
|
||||||
|
months,
|
||||||
|
};
|
||||||
|
|
||||||
|
render_html(template)
|
||||||
|
}
|
||||||
90
src/server/server.rs
Normal file
90
src/server/server.rs
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
|
use axum::Router;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tokio::signal;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use crate::domain::repositories::{RoastRepository, RoasterRepository, TimelineEventRepository};
|
||||||
|
use crate::infrastructure::database::Database;
|
||||||
|
use crate::infrastructure::repositories::roasters::SqlRoasterRepository;
|
||||||
|
use crate::infrastructure::repositories::roasts::SqlRoastRepository;
|
||||||
|
use crate::infrastructure::repositories::timeline_events::SqlTimelineEventRepository;
|
||||||
|
use crate::server::routes::app_router;
|
||||||
|
|
||||||
|
pub struct ServerConfig {
|
||||||
|
pub bind_address: SocketAddr,
|
||||||
|
pub database_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub roaster_repo: Arc<dyn RoasterRepository>,
|
||||||
|
pub roast_repo: Arc<dyn RoastRepository>,
|
||||||
|
pub timeline_repo: Arc<dyn TimelineEventRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn new(
|
||||||
|
roaster_repo: Arc<dyn RoasterRepository>,
|
||||||
|
roast_repo: Arc<dyn RoastRepository>,
|
||||||
|
timeline_repo: Arc<dyn TimelineEventRepository>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
roaster_repo,
|
||||||
|
roast_repo,
|
||||||
|
timeline_repo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
|
||||||
|
let database = Database::connect(&config.database_url)
|
||||||
|
.await
|
||||||
|
.context("failed to connect to database")?;
|
||||||
|
database.migrate().await?;
|
||||||
|
|
||||||
|
let roaster_repo = Arc::new(SqlRoasterRepository::new(database.clone_pool()));
|
||||||
|
let roast_repo = Arc::new(SqlRoastRepository::new(database.clone_pool()));
|
||||||
|
let timeline_repo = Arc::new(SqlTimelineEventRepository::new(database.clone_pool()));
|
||||||
|
let state = AppState::new(roaster_repo, roast_repo, timeline_repo);
|
||||||
|
|
||||||
|
let listener = TcpListener::bind(config.bind_address)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("failed to bind to {}", config.bind_address))?;
|
||||||
|
|
||||||
|
let app: Router = app_router(state);
|
||||||
|
|
||||||
|
info!(address = %config.bind_address, "starting HTTP server");
|
||||||
|
|
||||||
|
axum::serve(listener, app)
|
||||||
|
.with_graceful_shutdown(shutdown_signal())
|
||||||
|
.await
|
||||||
|
.context("server terminated unexpectedly")?;
|
||||||
|
|
||||||
|
info!("server shutdown complete");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn shutdown_signal() {
|
||||||
|
let ctrl_c = async {
|
||||||
|
signal::ctrl_c()
|
||||||
|
.await
|
||||||
|
.expect("failed to install Ctrl+C handler");
|
||||||
|
};
|
||||||
|
|
||||||
|
let terminate = async {
|
||||||
|
signal::unix::signal(signal::unix::SignalKind::terminate())
|
||||||
|
.expect("failed to install SIGTERM handler")
|
||||||
|
.recv()
|
||||||
|
.await;
|
||||||
|
};
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = ctrl_c => {},
|
||||||
|
_ = terminate => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
23
templates/base.html
Normal file
23
templates/base.html
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-star-root>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>{% block title %}Brewlog{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script
|
||||||
|
type="module"
|
||||||
|
src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.6/bundles/datastar.js"
|
||||||
|
></script>
|
||||||
|
{% block head %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-amber-50 text-stone-900">
|
||||||
|
<main class="mx-auto flex w-full max-w-5xl flex-col gap-6 px-6 py-10">
|
||||||
|
{% include "nav.html" %} {% block content %}{% endblock %}
|
||||||
|
<footer class="mt-8 text-xs text-stone-500">
|
||||||
|
<p>Built with Axum, Tailwind CSS, and Datastar.</p>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
templates/favicon.ico
Normal file
BIN
templates/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
8
templates/nav.html
Normal file
8
templates/nav.html
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<nav class="flex items-center justify-between gap-4 rounded-lg border border-amber-300 bg-amber-100/80 p-3 text-sm text-stone-600">
|
||||||
|
<div class="font-semibold uppercase tracking-[0.25em] text-amber-700"><a href="/timeline">Brewlog</a></div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<a class="border-b-2 pb-1 transition {% if nav_active == "roasters" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/roasters">Roasters</a>
|
||||||
|
<a class="border-b-2 pb-1 transition {% if nav_active == "roasts" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/roasts">Roasts</a>
|
||||||
|
<a class="border-b-2 pb-1 transition {% if nav_active == "timeline" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/timeline">Timeline</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
255
templates/partials/roast_list.html
Normal file
255
templates/partials/roast_list.html
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
<div id="roast-list" class="mt-6" data-star-scope="roasts">
|
||||||
|
{% if roasts.items.is_empty() %}
|
||||||
|
<div
|
||||||
|
class="rounded-lg border border-dashed border-amber-300 bg-amber-100/40 px-4 py-6 text-sm text-stone-600"
|
||||||
|
>
|
||||||
|
<p class="text-center">No roasts recorded yet. Use the form above to add your first roast.</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<section class="rounded-lg border border-amber-300 bg-amber-100/80 shadow-sm">
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-3 border-b border-amber-200/70 px-4 py-3 text-xs text-stone-600 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Showing {{ roasts.start_index() }}–{{ roasts.end_index() }} of {{ roasts.total }}
|
||||||
|
roasts
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-1 rounded-full border border-amber-400 px-3 py-1 font-semibold transition hover:border-amber-500 hover:text-amber-800 disabled:cursor-not-allowed disabled:border-amber-200 disabled:text-amber-300"
|
||||||
|
{% if roasts.has_previous() %}
|
||||||
|
data-on:click="@get('{{ navigator.fragment_page_href(roasts.previous_page().unwrap()) }}', {responseOverrides: {selector: '#roast-list', mode: 'replace'}})"
|
||||||
|
{% else %}
|
||||||
|
disabled
|
||||||
|
{% endif %}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">←</span>
|
||||||
|
<span>Previous</span>
|
||||||
|
</button>
|
||||||
|
{% if roasts.is_showing_all() %}
|
||||||
|
<span class="font-semibold text-amber-800">Showing all results</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="font-semibold text-amber-800"
|
||||||
|
>Page {{ roasts.page }} of {{ roasts.total_pages() }}</span
|
||||||
|
>
|
||||||
|
{% endif %}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-1 rounded-full border border-amber-400 px-3 py-1 font-semibold transition hover:border-amber-500 hover:text-amber-800 disabled:cursor-not-allowed disabled:border-amber-200 disabled:text-amber-300"
|
||||||
|
{% if roasts.has_next() %}
|
||||||
|
data-on:click="@get('{{ navigator.fragment_page_href(roasts.next_page().unwrap()) }}', {responseOverrides: {selector: '#roast-list', mode: 'replace'}})"
|
||||||
|
{% else %}
|
||||||
|
disabled
|
||||||
|
{% endif %}
|
||||||
|
>
|
||||||
|
<span>Next</span>
|
||||||
|
<span aria-hidden="true">→</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center gap-2 text-xs font-semibold text-stone-600">
|
||||||
|
<span>Rows</span>
|
||||||
|
<select
|
||||||
|
class="rounded-md border border-amber-300 bg-white px-2 py-1 text-xs font-semibold text-amber-800 transition hover:border-amber-400"
|
||||||
|
data-on:change="@get(evt.target.selectedOptions[0].dataset.href, {responseOverrides: {selector: '#roast-list', mode: 'replace'}})"
|
||||||
|
>
|
||||||
|
<option value="5" data-href="{{ navigator.fragment_rows_href("5") }}" {% if roasts.is_page_size(5) %}selected{% endif %}>5</option>
|
||||||
|
<option value="10" data-href="{{ navigator.fragment_rows_href("10") }}" {% if roasts.is_page_size(10) %}selected{% endif %}>10</option>
|
||||||
|
<option value="20" data-href="{{ navigator.fragment_rows_href("20") }}" {% if roasts.is_page_size(20) %}selected{% endif %}>20</option>
|
||||||
|
<option value="50" data-href="{{ navigator.fragment_rows_href("50") }}" {% if roasts.is_page_size(50) %}selected{% endif %}>50</option>
|
||||||
|
<option value="all" data-href="{{ navigator.fragment_rows_href("all") }}" {% if roasts.is_showing_all() %}selected{% endif %}>All</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-amber-200 text-left text-sm text-stone-700">
|
||||||
|
<thead class="bg-amber-200/60 text-xs font-semibold tracking-wide text-amber-900">
|
||||||
|
<tr>
|
||||||
|
{% set created_sorted = navigator.is_sorted_by("created-at") %}
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
class="px-4 py-3"
|
||||||
|
aria-sort="{% if created_sorted %}{% if navigator.sort_direction() == "asc" %}ascending{% else %}descending{% endif %}{% else %}none{% endif %}"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-1 text-amber-900 hover:text-amber-700"
|
||||||
|
data-on:click="@get('{{ navigator.fragment_sort_href("created-at") }}', {responseOverrides: {selector: '#roast-list', mode: 'replace'}})"
|
||||||
|
>
|
||||||
|
<span>Added</span>
|
||||||
|
{% if created_sorted %}
|
||||||
|
{% if navigator.sort_direction() == "asc" %}
|
||||||
|
<span aria-hidden="true">↑</span>
|
||||||
|
{% else %}
|
||||||
|
<span aria-hidden="true">↓</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span aria-hidden="true">↕</span>
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
{% set name_sorted = navigator.is_sorted_by("name") %}
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
class="px-4 py-3"
|
||||||
|
aria-sort="{% if name_sorted %}{% if navigator.sort_direction() == "asc" %}ascending{% else %}descending{% endif %}{% else %}none{% endif %}"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-1 text-amber-900 hover:text-amber-700"
|
||||||
|
data-on:click="@get('{{ navigator.fragment_sort_href("name") }}', {responseOverrides: {selector: '#roast-list', mode: 'replace'}})"
|
||||||
|
>
|
||||||
|
<span>Roast</span>
|
||||||
|
{% if name_sorted %}
|
||||||
|
{% if navigator.sort_direction() == "asc" %}
|
||||||
|
<span aria-hidden="true">↑</span>
|
||||||
|
{% else %}
|
||||||
|
<span aria-hidden="true">↓</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span aria-hidden="true">↕</span>
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
{% set roaster_sorted = navigator.is_sorted_by("roaster") %}
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
class="px-4 py-3"
|
||||||
|
aria-sort="{% if roaster_sorted %}{% if navigator.sort_direction() == "asc" %}ascending{% else %}descending{% endif %}{% else %}none{% endif %}"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-1 text-amber-900 hover:text-amber-700"
|
||||||
|
data-on:click="@get('{{ navigator.fragment_sort_href("roaster") }}', {responseOverrides: {selector: '#roast-list', mode: 'replace'}})"
|
||||||
|
>
|
||||||
|
<span>Roaster</span>
|
||||||
|
{% if roaster_sorted %}
|
||||||
|
{% if navigator.sort_direction() == "asc" %}
|
||||||
|
<span aria-hidden="true">↑</span>
|
||||||
|
{% else %}
|
||||||
|
<span aria-hidden="true">↓</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span aria-hidden="true">↕</span>
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
{% set origin_sorted = navigator.is_sorted_by("origin") %}
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
class="px-4 py-3"
|
||||||
|
aria-sort="{% if origin_sorted %}{% if navigator.sort_direction() == "asc" %}ascending{% else %}descending{% endif %}{% else %}none{% endif %}"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-1 text-amber-900 hover:text-amber-700"
|
||||||
|
data-on:click="@get('{{ navigator.fragment_sort_href("origin") }}', {responseOverrides: {selector: '#roast-list', mode: 'replace'}})"
|
||||||
|
>
|
||||||
|
<span>Origin</span>
|
||||||
|
{% if origin_sorted %}
|
||||||
|
{% if navigator.sort_direction() == "asc" %}
|
||||||
|
<span aria-hidden="true">↑</span>
|
||||||
|
{% else %}
|
||||||
|
<span aria-hidden="true">↓</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span aria-hidden="true">↕</span>
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
{% set producer_sorted = navigator.is_sorted_by("producer") %}
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
class="px-4 py-3"
|
||||||
|
aria-sort="{% if producer_sorted %}{% if navigator.sort_direction() == "asc" %}ascending{% else %}descending{% endif %}{% else %}none{% endif %}"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-1 text-amber-900 hover:text-amber-700"
|
||||||
|
data-on:click="@get('{{ navigator.fragment_sort_href("producer") }}', {responseOverrides: {selector: '#roast-list', mode: 'replace'}})"
|
||||||
|
>
|
||||||
|
<span>Producer</span>
|
||||||
|
{% if producer_sorted %}
|
||||||
|
{% if navigator.sort_direction() == "asc" %}
|
||||||
|
<span aria-hidden="true">↑</span>
|
||||||
|
{% else %}
|
||||||
|
<span aria-hidden="true">↓</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span aria-hidden="true">↕</span>
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-4 py-3">Notes</th>
|
||||||
|
<th scope="col" class="px-4 py-3 text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-amber-200/70">
|
||||||
|
{% for roast in roasts.items %}
|
||||||
|
<tr
|
||||||
|
data-star-key="{{ roast.full_id }}"
|
||||||
|
data-sort-created-at="{{ roast.created_at_sort_key }}"
|
||||||
|
data-sort-name="{{ roast.name }}"
|
||||||
|
data-sort-roaster="{{ roast.roaster_label }}"
|
||||||
|
data-sort-origin="{{ roast.origin }}"
|
||||||
|
data-sort-producer="{{ roast.producer }}"
|
||||||
|
class="bg-amber-50/40 transition hover:bg-amber-50"
|
||||||
|
>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-xs font-medium text-stone-600">
|
||||||
|
{{ roast.created_at }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<a
|
||||||
|
href="{{ roast.detail_path }}"
|
||||||
|
class="font-semibold text-amber-800 hover:text-amber-600"
|
||||||
|
>{{ roast.name }}</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">{{ roast.roaster_label }}</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">{{ roast.origin }}</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">{{ roast.producer }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
{% if roast.tasting_notes.is_empty() %}
|
||||||
|
<span class="text-xs text-stone-500">No tasting notes yet.</span>
|
||||||
|
{% else %}
|
||||||
|
<ul class="flex flex-wrap gap-2">
|
||||||
|
{% for note in roast.tasting_notes %}
|
||||||
|
<li>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded-full border border-amber-500/60 bg-amber-500/10 px-3 py-1 text-xs font-semibold text-amber-700"
|
||||||
|
>{{ note }}</span
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-stone-500 transition hover:text-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500"
|
||||||
|
title="Delete roast"
|
||||||
|
data-on:click="confirm('Delete this roast?') && @delete('/api/v1/roasts/{{ roast.full_id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#roast-list', mode: 'replace'}})"
|
||||||
|
>
|
||||||
|
<span class="sr-only">Delete</span>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M7.5 3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1H15a1 1 0 1 1 0 2h-.4l-.74 10.36A2 2 0 0 1 11.87 17H8.13a2 2 0 0 1-1.99-1.64L5.4 5H5a1 1 0 1 1 0-2h2.5Zm.9 4.5a.75.75 0 0 1 .75.75v6a.75.75 0 1 1-1.5 0v-6a.75.75 0 0 1 .75-.75Zm3.4 0a.75.75 0 0 1 .75.75v6a.75.75 0 1 1-1.5 0v-6a.75.75 0 0 1 .75-.75Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
240
templates/partials/roaster_list.html
Normal file
240
templates/partials/roaster_list.html
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
<div id="roaster-list" class="mt-6" data-star-scope="roasters">
|
||||||
|
{% if roasters.items.is_empty() %}
|
||||||
|
<div
|
||||||
|
class="rounded-lg border border-dashed border-amber-300 bg-amber-100/40 px-4 py-6 text-sm text-stone-600"
|
||||||
|
>
|
||||||
|
<p class="text-center">
|
||||||
|
No roasters recorded yet. Use the form above to add your first roaster.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<section class="rounded-lg border border-amber-300 bg-amber-100/80 shadow-sm">
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-3 border-b border-amber-200/70 px-4 py-3 text-xs text-stone-600 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Showing {{ roasters.start_index() }}–{{ roasters.end_index() }} of {{ roasters.total
|
||||||
|
}} roasters
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-1 rounded-full border border-amber-400 px-3 py-1 font-semibold transition hover:border-amber-500 hover:text-amber-800 disabled:cursor-not-allowed disabled:border-amber-200 disabled:text-amber-300"
|
||||||
|
{% if roasters.has_previous() %}
|
||||||
|
data-on:click="@get('{{ navigator.fragment_page_href(roasters.previous_page().unwrap()) }}', {responseOverrides: {selector: '#roaster-list', mode: 'replace'}})"
|
||||||
|
{% else %}
|
||||||
|
disabled
|
||||||
|
{% endif %}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">←</span>
|
||||||
|
<span>Previous</span>
|
||||||
|
</button>
|
||||||
|
{% if roasters.is_showing_all() %}
|
||||||
|
<span class="font-semibold text-amber-800">Showing all results</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="font-semibold text-amber-800"
|
||||||
|
>Page {{ roasters.page }} of {{ roasters.total_pages() }}</span
|
||||||
|
>
|
||||||
|
{% endif %}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-1 rounded-full border border-amber-400 px-3 py-1 font-semibold transition hover:border-amber-500 hover:text-amber-800 disabled:cursor-not-allowed disabled:border-amber-200 disabled:text-amber-300"
|
||||||
|
{% if roasters.has_next() %}
|
||||||
|
data-on:click="@get('{{ navigator.fragment_page_href(roasters.next_page().unwrap()) }}', {responseOverrides: {selector: '#roaster-list', mode: 'replace'}})"
|
||||||
|
{% else %}
|
||||||
|
disabled
|
||||||
|
{% endif %}
|
||||||
|
>
|
||||||
|
<span>Next</span>
|
||||||
|
<span aria-hidden="true">→</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center gap-2 text-xs font-semibold text-stone-600">
|
||||||
|
<span>Rows</span>
|
||||||
|
<select
|
||||||
|
class="rounded-md border border-amber-300 bg-white px-2 py-1 text-xs font-semibold text-amber-800 transition hover:border-amber-400"
|
||||||
|
data-on:change="@get(evt.target.selectedOptions[0].dataset.href, {responseOverrides: {selector: '#roaster-list', mode: 'replace'}})"
|
||||||
|
>
|
||||||
|
<option value="5" data-href="{{ navigator.fragment_rows_href("5") }}" {% if roasters.is_page_size(5) %}selected{% endif %}>5</option>
|
||||||
|
<option value="10" data-href="{{ navigator.fragment_rows_href("10") }}" {% if roasters.is_page_size(10) %}selected{% endif %}>10</option>
|
||||||
|
<option value="20" data-href="{{ navigator.fragment_rows_href("20") }}" {% if roasters.is_page_size(20) %}selected{% endif %}>20</option>
|
||||||
|
<option value="50" data-href="{{ navigator.fragment_rows_href("50") }}" {% if roasters.is_page_size(50) %}selected{% endif %}>50</option>
|
||||||
|
<option value="all" data-href="{{ navigator.fragment_rows_href("all") }}" {% if roasters.is_showing_all() %}selected{% endif %}>All</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-amber-200 text-left text-sm text-stone-700">
|
||||||
|
<thead class="bg-amber-200/60 text-xs font-semibold tracking-wide text-amber-900">
|
||||||
|
<tr>
|
||||||
|
{% set created_sorted = navigator.is_sorted_by("created-at") %}
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
class="px-4 py-3"
|
||||||
|
aria-sort="{% if created_sorted %}{% if navigator.sort_direction() == "asc" %}ascending{% else %}descending{% endif %}{% else %}none{% endif %}"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-1 text-amber-900 hover:text-amber-700"
|
||||||
|
data-on:click="@get('{{ navigator.fragment_sort_href("created-at") }}', {responseOverrides: {selector: '#roaster-list', mode: 'replace'}})"
|
||||||
|
>
|
||||||
|
<span>Added</span>
|
||||||
|
{% if created_sorted %}
|
||||||
|
{% if navigator.sort_direction() == "asc" %}
|
||||||
|
<span aria-hidden="true">↑</span>
|
||||||
|
{% else %}
|
||||||
|
<span aria-hidden="true">↓</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span aria-hidden="true">↕</span>
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
{% set name_sorted = navigator.is_sorted_by("name") %}
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
class="px-4 py-3"
|
||||||
|
aria-sort="{% if name_sorted %}{% if navigator.sort_direction() == "asc" %}ascending{% else %}descending{% endif %}{% else %}none{% endif %}"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-1 text-amber-900 hover:text-amber-700"
|
||||||
|
data-on:click="@get('{{ navigator.fragment_sort_href("name") }}', {responseOverrides: {selector: '#roaster-list', mode: 'replace'}})"
|
||||||
|
>
|
||||||
|
<span>Name</span>
|
||||||
|
{% if name_sorted %}
|
||||||
|
{% if navigator.sort_direction() == "asc" %}
|
||||||
|
<span aria-hidden="true">↑</span>
|
||||||
|
{% else %}
|
||||||
|
<span aria-hidden="true">↓</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span aria-hidden="true">↕</span>
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
{% set country_sorted = navigator.is_sorted_by("country") %}
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
class="px-4 py-3"
|
||||||
|
aria-sort="{% if country_sorted %}{% if navigator.sort_direction() == "asc" %}ascending{% else %}descending{% endif %}{% else %}none{% endif %}"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-1 text-amber-900 hover:text-amber-700"
|
||||||
|
data-on:click="@get('{{ navigator.fragment_sort_href("country") }}', {responseOverrides: {selector: '#roaster-list', mode: 'replace'}})"
|
||||||
|
>
|
||||||
|
<span>Country</span>
|
||||||
|
{% if country_sorted %}
|
||||||
|
{% if navigator.sort_direction() == "asc" %}
|
||||||
|
<span aria-hidden="true">↑</span>
|
||||||
|
{% else %}
|
||||||
|
<span aria-hidden="true">↓</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span aria-hidden="true">↕</span>
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
{% set city_sorted = navigator.is_sorted_by("city") %}
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
class="px-4 py-3"
|
||||||
|
aria-sort="{% if city_sorted %}{% if navigator.sort_direction() == "asc" %}ascending{% else %}descending{% endif %}{% else %}none{% endif %}"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-1 text-amber-900 hover:text-amber-700"
|
||||||
|
data-on:click="@get('{{ navigator.fragment_sort_href("city") }}', {responseOverrides: {selector: '#roaster-list', mode: 'replace'}})"
|
||||||
|
>
|
||||||
|
<span>City</span>
|
||||||
|
{% if city_sorted %}
|
||||||
|
{% if navigator.sort_direction() == "asc" %}
|
||||||
|
<span aria-hidden="true">↑</span>
|
||||||
|
{% else %}
|
||||||
|
<span aria-hidden="true">↓</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span aria-hidden="true">↕</span>
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-4 py-3">Homepage</th>
|
||||||
|
<th scope="col" class="px-4 py-3">Notes</th>
|
||||||
|
<th scope="col" class="px-4 py-3 text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-amber-200/70">
|
||||||
|
{% for roaster in roasters.items %}
|
||||||
|
<tr
|
||||||
|
data-star-key="{{ roaster.id }}"
|
||||||
|
data-sort-created-at="{{ roaster.created_at_sort_key }}"
|
||||||
|
data-sort-name="{{ roaster.name }}"
|
||||||
|
data-sort-country="{{ roaster.country }}"
|
||||||
|
data-sort-city="{{ roaster.city }}"
|
||||||
|
class="bg-amber-50/40 transition hover:bg-amber-50"
|
||||||
|
>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-xs font-medium text-stone-600">
|
||||||
|
{{ roaster.created_at }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<a
|
||||||
|
href="{{ roaster.detail_path }}"
|
||||||
|
class="font-semibold text-amber-800 hover:text-amber-600"
|
||||||
|
>{{ roaster.name }}</a
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">{{ roaster.country }}</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">{{ roaster.city }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
{% if roaster.has_homepage %}
|
||||||
|
<a
|
||||||
|
class="inline-flex items-center justify-center rounded-md border border-amber-400 bg-amber-200/50 p-2 text-amber-800 transition hover:border-amber-500 hover:text-amber-900"
|
||||||
|
href="{{ roaster.homepage_url }}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
aria-label="Visit {{ roaster.name }} homepage"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M11.3 2a.7.7 0 0 0 0 1.4h3.3l-8.1 8.1a.7.7 0 1 0 1 1l8.1-8.1v3.3a.7.7 0 1 0 1.4 0V2.7A.7.7 0 0 0 16.3 2h-5Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M4.7 5.2a1.5 1.5 0 0 1 1.5-1.5h2.1a.7.7 0 1 0 0-1.4H6.2a2.9 2.9 0 0 0-2.9 2.9v7.6a2.9 2.9 0 0 0 2.9 2.9h7.6a2.9 2.9 0 0 0 2.9-2.9v-2.1a.7.7 0 1 0-1.4 0v2.1a1.5 1.5 0 0 1-1.5 1.5H6.1a1.5 1.5 0 0 1-1.5-1.5V5.2Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-stone-400">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-stone-600">{{ roaster.notes }}</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-stone-500 transition hover:text-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500"
|
||||||
|
title="Delete roaster"
|
||||||
|
data-on:click="confirm('Delete this roaster?') && @delete('/api/v1/roasters/{{ roaster.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#roaster-list', mode: 'replace'}})"
|
||||||
|
>
|
||||||
|
<span class="sr-only">Delete</span>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M7.5 3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1H15a1 1 0 1 1 0 2h-.4l-.74 10.36A2 2 0 0 1 11.87 17H8.13a2 2 0 0 1-1.99-1.64L5.4 5H5a1 1 0 1 1 0-2h2.5Zm.9 4.5a.75.75 0 0 1 .75.75v6a.75.75 0 1 1-1.5 0v-6a.75.75 0 0 1 .75-.75Zm3.4 0a.75.75 0 0 1 .75.75v6a.75.75 0 1 1-1.5 0v-6a.75.75 0 0 1 .75-.75Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
50
templates/roast_detail.html
Normal file
50
templates/roast_detail.html
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
{% extends "base.html" %} {% block title %}Brewlog · Roast · {{ roast.name }}{% endblock %} {%
|
||||||
|
block content %}
|
||||||
|
<header class="flex flex-col gap-2">
|
||||||
|
<h1 class="text-3xl font-semibold">{{ roast.name }}</h1>
|
||||||
|
<p class="max-w-2xl text-sm text-stone-600">Detailed view for this roast.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="grid gap-4 rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
|
||||||
|
<dl class="grid gap-2 text-sm text-stone-700">
|
||||||
|
<div class="flex justify-between gap-2">
|
||||||
|
<dt class="font-medium text-stone-500">Roaster</dt>
|
||||||
|
<dd class="text-right">{{ roast.roaster_label }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-2">
|
||||||
|
<dt class="font-medium text-stone-500">Origin</dt>
|
||||||
|
<dd class="text-right">{{ roast.origin }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-2">
|
||||||
|
<dt class="font-medium text-stone-500">Region</dt>
|
||||||
|
<dd class="text-right">{{ roast.region }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-2">
|
||||||
|
<dt class="font-medium text-stone-500">Producer</dt>
|
||||||
|
<dd class="text-right">{{ roast.producer }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-2">
|
||||||
|
<dt class="font-medium text-stone-500">Process</dt>
|
||||||
|
<dd class="text-right">{{ roast.process }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-2">
|
||||||
|
<dt class="font-medium text-stone-500">Created</dt>
|
||||||
|
<dd class="text-right">{{ roast.created_at }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{% if roast.tasting_notes.is_empty() %}
|
||||||
|
<p class="text-sm text-stone-600">No tasting notes yet.</p>
|
||||||
|
{% else %}
|
||||||
|
<ul class="flex flex-wrap gap-2 text-sm">
|
||||||
|
{% for note in roast.tasting_notes %}
|
||||||
|
<li>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded-full border border-amber-500/60 bg-amber-500/10 px-3 py-1 text-xs font-semibold text-amber-700"
|
||||||
|
>{{ note }}</span
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
116
templates/roaster_detail.html
Normal file
116
templates/roaster_detail.html
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
{% extends "base.html" %} {% block title %}Brewlog · Roaster · {{ roaster.name }}{% endblock %} {%
|
||||||
|
block content %}
|
||||||
|
<header class="flex flex-col gap-2">
|
||||||
|
<h1 class="text-3xl font-semibold">{{ roaster.name }}</h1>
|
||||||
|
<p class="max-w-2xl text-sm text-stone-600">Detailed view for {{ roaster.name }}.</p>
|
||||||
|
</header>
|
||||||
|
<section class="grid gap-4 rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
|
||||||
|
<dl class="grid gap-2 text-sm text-stone-700">
|
||||||
|
<div class="flex justify-between gap-2">
|
||||||
|
<dt class="font-medium text-stone-500">Country</dt>
|
||||||
|
<dd class="text-right">{{ roaster.country }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-2">
|
||||||
|
<dt class="font-medium text-stone-500">City</dt>
|
||||||
|
<dd class="text-right">{{ roaster.city }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<dt class="font-medium text-stone-500">Homepage</dt>
|
||||||
|
<dd class="flex justify-end">
|
||||||
|
{% if roaster.has_homepage %}
|
||||||
|
<a
|
||||||
|
class="inline-flex items-center gap-2 rounded-md border border-amber-400 bg-amber-200/60 px-3 py-1 text-xs font-semibold text-amber-800 transition hover:border-amber-500 hover:bg-amber-200 hover:text-amber-900"
|
||||||
|
href="{{ roaster.homepage_url }}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
aria-label="Visit {{ roaster.name }} homepage"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M11.3 2a.7.7 0 0 0 0 1.4h3.3l-8.1 8.1a.7.7 0 1 0 1 1l8.1-8.1v3.3a.7.7 0 1 0 1.4 0V2.7A.7.7 0 0 0 16.3 2h-5Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M4.7 5.2a1.5 1.5 0 0 1 1.5-1.5h2.1a.7.7 0 1 0 0-1.4H6.2a2.9 2.9 0 0 0-2.9 2.9v7.6a2.9 2.9 0 0 0 2.9 2.9h7.6a2.9 2.9 0 0 0 2.9-2.9v-2.1a.7.7 0 1 0-1.4 0v2.1a1.5 1.5 0 0 1-1.5 1.5H6.1a1.5 1.5 0 0 1-1.5-1.5V5.2Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>Visit</span>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span>—</span>
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-2">
|
||||||
|
<dt class="font-medium text-stone-500">Created</dt>
|
||||||
|
<dd class="text-right">{{ roaster.created_at }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<p class="text-sm text-stone-600">{{ roaster.notes }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="flex flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-2xl font-semibold text-amber-800">Roasts</h2>
|
||||||
|
</div>
|
||||||
|
{% if roasts.is_empty() %}
|
||||||
|
<p class="text-sm text-stone-600">This roaster has no roasts yet. Use the CLI to add one.</p>
|
||||||
|
{% else %}
|
||||||
|
<div class="overflow-x-auto rounded-lg border border-amber-300 bg-amber-100/80 shadow-sm">
|
||||||
|
<table class="min-w-full divide-y divide-amber-200 text-left text-sm text-stone-700">
|
||||||
|
<thead class="bg-amber-200/60 text-xs font-semibold tracking-wide text-amber-900">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="px-4 py-3">Added</th>
|
||||||
|
<th scope="col" class="px-4 py-3">Roast</th>
|
||||||
|
<th scope="col" class="px-4 py-3">Origin</th>
|
||||||
|
<th scope="col" class="px-4 py-3">Region</th>
|
||||||
|
<th scope="col" class="px-4 py-3">Producer</th>
|
||||||
|
<th scope="col" class="px-4 py-3">Process</th>
|
||||||
|
<th scope="col" class="px-4 py-3">Tasting Notes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-amber-200/70">
|
||||||
|
{% for roast in roasts %}
|
||||||
|
<tr
|
||||||
|
data-star-key="{{ roast.full_id }}"
|
||||||
|
class="bg-amber-50/40 transition hover:bg-amber-50"
|
||||||
|
>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-xs font-medium text-stone-600">
|
||||||
|
{{ roast.created_at }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<a
|
||||||
|
href="{{ roast.detail_path }}"
|
||||||
|
class="font-semibold text-amber-800 hover:text-amber-600"
|
||||||
|
>{{ roast.name }}</a
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">{{ roast.origin }}</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">{{ roast.region }}</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">{{ roast.producer }}</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">{{ roast.process }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
{% if roast.tasting_notes.is_empty() %}
|
||||||
|
<span class="text-xs text-stone-500">No tasting notes yet.</span>
|
||||||
|
{% else %}
|
||||||
|
<ul class="flex flex-wrap gap-2">
|
||||||
|
{% for note in roast.tasting_notes %}
|
||||||
|
<li>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded-full border border-amber-500/60 bg-amber-500/10 px-3 py-1 text-xs font-semibold text-amber-700"
|
||||||
|
>{{ note }}</span
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
101
templates/roasters.html
Normal file
101
templates/roasters.html
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
{% extends "base.html" %} {% block title %}Brewlog · Roasters{% endblock %} {% block content %}
|
||||||
|
<section data-signals:show-form="false">
|
||||||
|
<header class="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<h1 class="text-3xl font-semibold">Roasters</h1>
|
||||||
|
<p class="max-w-2xl text-sm text-stone-600">Browse the coffee roasters known to Brewlog.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-10 w-10 items-center justify-center rounded-full border border-amber-500 text-2xl font-semibold text-amber-700 transition hover:border-amber-400 hover:text-amber-600"
|
||||||
|
data-class:hidden="$showForm"
|
||||||
|
data-on:click="$showForm = true"
|
||||||
|
aria-label="Add new roaster"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">+</span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mt-6 rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm"
|
||||||
|
data-show="$showForm"
|
||||||
|
style="display: none"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-amber-700">New Roaster</h2>
|
||||||
|
<p class="mt-1 text-sm text-stone-600">
|
||||||
|
Provide the core details and Brewlog will keep track of everything for you.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="/api/v1/roasters"
|
||||||
|
class="mt-4 flex flex-col gap-4"
|
||||||
|
data-on:submit="@post('/api/v1/roasters?{{ navigator.query_for_page(1) }}', {contentType: 'form', responseOverrides: {selector: '#roaster-list', mode: 'replace'}})"
|
||||||
|
data-ref="form"
|
||||||
|
data-on:datastar-fetch="evt.detail.type === 'finished' && ($showForm = false, $form && $form.reset())"
|
||||||
|
>
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="text-stone-700">Name *</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
class="input-field"
|
||||||
|
placeholder="Example Coffee Roasters"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="text-stone-700">Country *</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="country"
|
||||||
|
required
|
||||||
|
class="input-field"
|
||||||
|
placeholder="United States"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="text-stone-700">City</span>
|
||||||
|
<input type="text" name="city" class="input-field" placeholder="Portland" />
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="text-stone-700">Homepage</span>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
name="homepage"
|
||||||
|
class="input-field"
|
||||||
|
placeholder="https://example.coffee"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="sm:col-span-2 flex flex-col gap-1 text-sm">
|
||||||
|
<span class="text-stone-700">Notes</span>
|
||||||
|
<textarea
|
||||||
|
name="notes"
|
||||||
|
rows="3"
|
||||||
|
class="input-field"
|
||||||
|
placeholder="Short description, sourcing approach, or contact details"
|
||||||
|
></textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-amber-300 px-4 py-2 text-sm font-semibold text-stone-700 transition hover:border-amber-400 hover:text-stone-800"
|
||||||
|
data-on:click="($showForm = false, $form && $form.reset())"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-amber-600 px-4 py-2 text-sm font-semibold text-amber-50 transition hover:bg-amber-500"
|
||||||
|
>
|
||||||
|
Save Roaster
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% include "partials/roaster_list.html" %} {% endblock %}
|
||||||
125
templates/roasts.html
Normal file
125
templates/roasts.html
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
{% extends "base.html" %} {% block title %}Brewlog · Roasts{% endblock %} {% block content %}
|
||||||
|
<section data-signals:show-form="false">
|
||||||
|
<header class="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<h1 class="text-3xl font-semibold">Roasts</h1>
|
||||||
|
<p class="max-w-2xl text-sm text-stone-600">Explore the latest roasts logged in Brewlog.</p>
|
||||||
|
</div>
|
||||||
|
{% if !roaster_options.is_empty() %}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-10 w-10 items-center justify-center rounded-full border border-amber-500 text-2xl font-semibold text-amber-700 transition hover:border-amber-400 hover:text-amber-600"
|
||||||
|
data-class:hidden="$showForm"
|
||||||
|
data-on:click="$showForm = true"
|
||||||
|
aria-label="Add new roast"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">+</span>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if roaster_options.is_empty() %}
|
||||||
|
<div
|
||||||
|
class="mt-6 rounded-lg border border-dashed border-amber-300 bg-amber-100/60 p-5 text-sm text-stone-600"
|
||||||
|
>
|
||||||
|
<h2 class="text-lg font-semibold text-amber-700">Add a roaster first</h2>
|
||||||
|
<p class="mt-2">
|
||||||
|
Roasts need a roaster.
|
||||||
|
<a class="text-amber-700 hover:text-amber-600 underline" href="/roasters"
|
||||||
|
>Create a roaster</a
|
||||||
|
>
|
||||||
|
to enable this form.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div
|
||||||
|
class="mt-6 rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm"
|
||||||
|
data-show="$showForm"
|
||||||
|
style="display: none"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-amber-700">New Roast</h2>
|
||||||
|
<p class="mt-1 text-sm text-stone-600">
|
||||||
|
Select a roaster, describe the roast, and Brewlog will take care of the rest.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="/api/v1/roasts"
|
||||||
|
class="mt-4 flex flex-col gap-4"
|
||||||
|
data-on:submit="@post('/api/v1/roasts?{{ navigator.query_for_page(1) }}', {contentType: 'form', responseOverrides: {selector: '#roast-list', mode: 'replace'}})"
|
||||||
|
data-ref="form"
|
||||||
|
data-on:datastar-fetch="evt.detail.type === 'finished' && ($showForm = false, $form && $form.reset())"
|
||||||
|
>
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="text-stone-700">Roaster *</span>
|
||||||
|
<select name="roaster_id" required class="input-field">
|
||||||
|
<option value="">Select a roaster</option>
|
||||||
|
{% for roaster in roaster_options %}
|
||||||
|
<option value="{{ roaster.id }}">{{ roaster.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="text-stone-700">Roast Name *</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
class="input-field"
|
||||||
|
placeholder="Ethiopia Yirgacheffe"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="text-stone-700">Origin</span>
|
||||||
|
<input type="text" name="origin" class="input-field" placeholder="Ethiopia" />
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="text-stone-700">Region</span>
|
||||||
|
<input type="text" name="region" class="input-field" placeholder="Guji" />
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="text-stone-700">Producer</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="producer"
|
||||||
|
class="input-field"
|
||||||
|
placeholder="Chelbesa Cooperative"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="text-stone-700">Process</span>
|
||||||
|
<input type="text" name="process" class="input-field" placeholder="Washed" />
|
||||||
|
</label>
|
||||||
|
<label class="sm:col-span-2 flex flex-col gap-1 text-sm">
|
||||||
|
<span class="text-stone-700">Tasting Notes (comma or newline separated)</span>
|
||||||
|
<textarea
|
||||||
|
name="tasting_notes"
|
||||||
|
rows="2"
|
||||||
|
class="input-field"
|
||||||
|
placeholder="Blueberry, Jasmine"
|
||||||
|
></textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-amber-300 px-4 py-2 text-sm font-semibold text-stone-700 transition hover:border-amber-400 hover:text-stone-800"
|
||||||
|
data-on:click="($showForm = false, $form && $form.reset())"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-amber-600 px-4 py-2 text-sm font-semibold text-amber-50 transition hover:bg-amber-500"
|
||||||
|
>
|
||||||
|
Save Roast
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% include "partials/roast_list.html" %} {% endblock %}
|
||||||
26
templates/styles.css
Normal file
26
templates/styles.css
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid rgba(120, 53, 15, 0.3);
|
||||||
|
background-color: rgba(254, 243, 199, 0.85);
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
color: #4a2f0b;
|
||||||
|
transition: border-color 150ms ease, box-shadow 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(180, 83, 9, 0.6);
|
||||||
|
box-shadow: 0 0 0 2px rgba(180, 83, 9, 0.2);
|
||||||
|
}
|
||||||
120
templates/timeline.html
Normal file
120
templates/timeline.html
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
{% extends "base.html" %} {% block title %}Brewlog · Timeline{% endblock %} {% block content %}
|
||||||
|
<header class="flex flex-col gap-2">
|
||||||
|
<h1 class="text-3xl font-semibold">Timeline</h1>
|
||||||
|
<p class="max-w-2xl text-sm text-stone-600">
|
||||||
|
Follow the history of roasters and roasts in Brewlog.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="mt-8 grid gap-12 lg:grid-cols-[minmax(0,1fr),14rem]">
|
||||||
|
<section class="space-y-12">
|
||||||
|
{% if months.is_empty() %}
|
||||||
|
<p
|
||||||
|
class="rounded-lg border border-dashed border-amber-300 bg-amber-100/60 p-6 text-sm text-stone-600"
|
||||||
|
>
|
||||||
|
No events yet. Create roasters or roasts to populate the timeline.
|
||||||
|
</p>
|
||||||
|
{% else %} {% for month in months %}
|
||||||
|
<div id="{{ month.anchor }}" class="scroll-mt-24">
|
||||||
|
<h2 class="text-2xl font-semibold text-amber-800">{{ month.heading }}</h2>
|
||||||
|
<ol class="mt-6 space-y-8 border-l-2 border-amber-200 pl-4 sm:pl-8">
|
||||||
|
{% for event in month.events %}
|
||||||
|
<li class="relative pl-6 sm:pl-10">
|
||||||
|
<span
|
||||||
|
class="absolute left-[-10px] top-2 h-5 w-5 rounded-full border-[6px] border-amber-50 {{ event.accent_class }} sm:left-[-14px]"
|
||||||
|
></span>
|
||||||
|
<div class="rounded-lg border {{ event.card_border_class }} p-5 shadow-sm">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold {{ event.badge_class }}"
|
||||||
|
>{{ event.kind_label }}</span
|
||||||
|
>
|
||||||
|
<time
|
||||||
|
datetime="{{ event.iso_timestamp }}"
|
||||||
|
class="text-xs uppercase tracking-wide text-stone-500"
|
||||||
|
>
|
||||||
|
{{ event.date_label }}{% if let Some(label) = event.time_label %} · {{ label }}{%
|
||||||
|
endif %}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<h3 class="mt-3 flex items-center gap-2 text-lg font-semibold {{ event.title_class }}">
|
||||||
|
<a href="{{ event.link }}" class="hover:text-amber-600">{{ event.title }}</a>
|
||||||
|
{% if let Some(url) = event.external_link %}
|
||||||
|
<a
|
||||||
|
href="{{ url }}"
|
||||||
|
class="inline-flex h-7 w-7 items-center justify-center text-amber-700 transition hover:text-amber-500"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
aria-label="Open external link"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M11.3 2a.7.7 0 0 0 0 1.4h3.3l-8.1 8.1a.7.7 0 1 0 1 1l8.1-8.1v3.3a.7.7 0 1 0 1.4 0V2.7A.7.7 0 0 0 16.3 2h-5Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M4.7 5.2a1.5 1.5 0 0 1 1.5-1.5h2.1a.7.7 0 1 0 0-1.4H6.2a2.9 2.9 0 0 0-2.9 2.9v7.6a2.9 2.9 0 0 0 2.9 2.9h7.6a2.9 2.9 0 0 0 2.9-2.9v-2.1a.7.7 0 1 0-1.4 0v2.1a1.5 1.5 0 0 1-1.5 1.5H6.1a1.5 1.5 0 0 1-1.5-1.5V5.2Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="sr-only">Open external link</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</h3>
|
||||||
|
{% if event.details.len() > 0 %}
|
||||||
|
<dl class="mt-4 flex flex-col gap-2 text-sm text-stone-600">
|
||||||
|
{% for detail in event.details %}
|
||||||
|
<div class="flex justify-between gap-2">
|
||||||
|
<dt class="font-medium text-stone-500">{{ detail.label }}</dt>
|
||||||
|
<dd class="text-right">{{ detail.value }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</dl>
|
||||||
|
{% endif %} {% if let Some(notes) = event.tasting_notes %} {% if notes.is_empty() %}
|
||||||
|
<p class="mt-4 text-sm text-stone-600">No tasting notes yet.</p>
|
||||||
|
{% else %}
|
||||||
|
<ul class="mt-4 flex flex-wrap gap-2">
|
||||||
|
{% for note in notes %}
|
||||||
|
<li>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded-full border border-amber-500/60 bg-amber-500/10 px-3 py-1 text-xs font-semibold text-amber-700"
|
||||||
|
>{{ note }}</span
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %} {% endif %}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
{% endfor %} {% endif %}
|
||||||
|
</section>
|
||||||
|
<aside class="sticky top-24 self-start lg:top-28">
|
||||||
|
<div class="rounded-lg border border-amber-200 bg-amber-50/80 p-4 shadow-sm">
|
||||||
|
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500">Jump to</h2>
|
||||||
|
{% if months.is_empty() %}
|
||||||
|
<p class="mt-3 text-sm text-stone-600">
|
||||||
|
Timeline navigation will appear once events are available.
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<nav class="mt-3 flex flex-col gap-2 text-sm">
|
||||||
|
{% for month in months %}
|
||||||
|
<a
|
||||||
|
href="#{{ month.anchor }}"
|
||||||
|
class="rounded-md border border-transparent px-3 py-2 transition hover:border-amber-400 hover:bg-amber-100/70 hover:text-amber-700"
|
||||||
|
>{{ month.heading }}</a
|
||||||
|
>
|
||||||
|
{% endfor %}
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
Loading…
Reference in a new issue