feat: added cellar app 77eb6c4e
Steve · 2026-04-03 23:49 31 file(s) · +2081 −0
Cargo.lock +23 −0
643 643
]
644 644
645 645
[[package]]
646 +
name = "cellar"
647 +
version = "0.1.0"
648 +
dependencies = [
649 +
 "andromeda-auth",
650 +
 "askama 0.15.6",
651 +
 "askama_web",
652 +
 "axum",
653 +
 "base64",
654 +
 "dotenvy",
655 +
 "nanoid",
656 +
 "rand 0.8.5",
657 +
 "reqwest 0.12.28",
658 +
 "rusqlite",
659 +
 "rust-embed",
660 +
 "serde",
661 +
 "serde_json",
662 +
 "subtle",
663 +
 "tokio",
664 +
 "tracing",
665 +
 "tracing-subscriber",
666 +
]
667 +
668 +
[[package]]
646 669
name = "cesu8"
647 670
version = "1.1.0"
648 671
source = "registry+https://github.com/rust-lang/crates.io-index"
Cargo.toml +1 −0
6 6
    "apps/jotts",
7 7
    "apps/og",
8 8
    "apps/shrink",
9 +
    "apps/cellar",
9 10
    "crates/auth",
10 11
]
11 12
resolver = "3"
apps/cellar/.env.example (added) +6 −0
1 +
CELLAR_PASSWORD=changeme
2 +
CELLAR_DB_PATH=cellar.sqlite
3 +
ANTHROPIC_API_KEY=
4 +
COOKIE_SECURE=false
5 +
HOST=127.0.0.1
6 +
PORT=3000
apps/cellar/Cargo.toml (added) +27 −0
1 +
[package]
2 +
name = "cellar"
3 +
version = "0.1.0"
4 +
edition = "2024"
5 +
description = "Personal wine tasting log"
6 +
license = "MIT"
7 +
repository = "https://github.com/stevedylandev/andromeda"
8 +
homepage = "https://github.com/stevedylandev/andromeda"
9 +
10 +
[dependencies]
11 +
axum = { workspace = true, features = ["multipart"] }
12 +
tokio = { workspace = true }
13 +
serde = { workspace = true }
14 +
serde_json = { workspace = true }
15 +
rusqlite = { workspace = true }
16 +
nanoid = { workspace = true }
17 +
rust-embed = { workspace = true }
18 +
dotenvy = { workspace = true }
19 +
subtle = { workspace = true }
20 +
rand = { workspace = true }
21 +
tracing = { workspace = true }
22 +
tracing-subscriber = { workspace = true }
23 +
andromeda-auth = { workspace = true }
24 +
askama = "0.15"
25 +
askama_web = { version = "0.15", features = ["axum-0.8"] }
26 +
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
27 +
base64 = "0.22"
apps/cellar/Dockerfile (added) +39 −0
1 +
FROM rust:1-slim-bookworm AS builder
2 +
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
3 +
WORKDIR /app
4 +
5 +
# Copy workspace manifests
6 +
COPY Cargo.toml Cargo.lock .
7 +
COPY crates/auth/Cargo.toml crates/auth/
8 +
COPY apps/sipp/Cargo.toml apps/sipp/
9 +
COPY apps/feeds/Cargo.toml apps/feeds/
10 +
COPY apps/parcels/Cargo.toml apps/parcels/
11 +
COPY apps/jotts/Cargo.toml apps/jotts/
12 +
COPY apps/og/Cargo.toml apps/og/
13 +
COPY apps/shrink/Cargo.toml apps/shrink/
14 +
COPY apps/cellar/Cargo.toml apps/cellar/
15 +
16 +
# Create stubs for dependency caching
17 +
RUN mkdir -p crates/auth/src && echo '' > crates/auth/src/lib.rs \
18 +
    && for app in sipp feeds parcels jotts og shrink cellar; do \
19 +
         mkdir -p apps/$app/src && echo 'fn main() {}' > apps/$app/src/main.rs; \
20 +
       done
21 +
22 +
RUN cargo build --release -p cellar
23 +
24 +
# Copy real source
25 +
COPY crates/auth/src crates/auth/src
26 +
COPY apps/cellar/src apps/cellar/src
27 +
COPY apps/cellar/static apps/cellar/static
28 +
COPY apps/cellar/templates apps/cellar/templates
29 +
30 +
RUN touch apps/cellar/src/*.rs crates/auth/src/*.rs && cargo build --release -p cellar
31 +
32 +
FROM debian:bookworm-slim
33 +
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
34 +
COPY --from=builder /app/target/release/cellar /usr/local/bin/cellar
35 +
WORKDIR /data
36 +
EXPOSE 3000
37 +
ENV HOST=0.0.0.0
38 +
ENV PORT=3000
39 +
CMD ["cellar"]
apps/cellar/README.md (added) +86 −0
1 +
# Cellar
2 +
3 +
![cover](https://files.stevedylan.dev/cellar-demo.png)
4 +
5 +
A minimal wine collection tracker
6 +
7 +
## Quickstart
8 +
9 +
```bash
10 +
git clone https://github.com/stevedylandev/cellar.git
11 +
cd cellar
12 +
cp .env.example .env
13 +
# Edit .env with your password and Anthropic API key
14 +
cargo build --release
15 +
./target/release/cellar
16 +
```
17 +
18 +
### Environment Variables
19 +
20 +
| Variable | Description | Default |
21 +
|---|---|---|
22 +
| `CELLAR_PASSWORD` | Password for login authentication | `changeme` |
23 +
| `CELLAR_DB_PATH` | SQLite database file path | `cellar.sqlite` |
24 +
| `ANTHROPIC_API_KEY` | Anthropic API key for AI features | |
25 +
| `HOST` | Server bind address | `127.0.0.1` |
26 +
| `PORT` | Server port | `3000` |
27 +
| `COOKIE_SECURE` | Enable HTTPS-only cookies | `false` |
28 +
29 +
## Overview
30 +
31 +
A simple, self-hosted wine collection app built with Rust. Here's a few highlights:
32 +
- Single Rust binary with embedded assets
33 +
- Password authentication with session cookies
34 +
- Add, edit, and delete wines from your collection
35 +
- AI-powered tasting notes via Claude
36 +
- Pentagon visualizations for wine profiles
37 +
- Dark themed UI with Commit Mono font
38 +
- SQLite for persistent storage
39 +
40 +
## Structure
41 +
42 +
```
43 +
cellar/
44 +
├── src/
45 +
│   ├── main.rs        # App entrypoint, env vars, starts server
46 +
│   ├── server.rs      # Axum router, HTTP handlers, and templates
47 +
│   ├── auth.rs        # Password verification and session management
48 +
│   ├── claude.rs      # Anthropic API integration for tasting notes
49 +
│   └── db.rs          # SQLite database layer (wines, sessions)
50 +
├── templates/         # Askama HTML templates
51 +
│   ├── base.html      # Base layout with header and nav
52 +
│   ├── login.html     # Login page
53 +
│   ├── index.html     # Wine collection list
54 +
│   ├── wine.html      # Single wine display
55 +
│   ├── wine_form.html # Add/edit wine form
56 +
│   └── admin.html     # Admin page
57 +
├── static/            # Favicons, og:image, styles, and webmanifest
58 +
├── Dockerfile         # Multi-stage build (Rust + Debian slim)
59 +
└── docker-compose.yml
60 +
```
61 +
62 +
## Deployment
63 +
64 +
### Docker (recommended)
65 +
66 +
```bash
67 +
git clone https://github.com/stevedylandev/cellar.git
68 +
cd cellar
69 +
cp .env.example .env
70 +
# Edit .env with your password and Anthropic API key
71 +
docker compose up -d
72 +
```
73 +
74 +
This will start Cellar on port `3000` with a persistent volume for the SQLite database.
75 +
76 +
### Binary
77 +
78 +
```bash
79 +
cargo build --release
80 +
```
81 +
82 +
The resulting binary at `./target/release/cellar` is self-contained with all assets embedded. Copy it to your server with a configured `.env` file and run it directly.
83 +
84 +
## License
85 +
86 +
[MIT](LICENSE)
apps/cellar/docker-compose.yml (added) +20 −0
1 +
services:
2 +
  app:
3 +
    build:
4 +
      context: ../..
5 +
      dockerfile: apps/cellar/Dockerfile
6 +
    ports:
7 +
      - "${PORT:-3000}:${PORT:-3000}"
8 +
    environment:
9 +
      - CELLAR_PASSWORD=${CELLAR_PASSWORD:-changeme}
10 +
      - CELLAR_DB_PATH=/data/cellar.sqlite
11 +
      - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
12 +
      - COOKIE_SECURE=false
13 +
      - HOST=0.0.0.0
14 +
      - PORT=${PORT:-3000}
15 +
    volumes:
16 +
      - cellar-data:/data
17 +
    restart: unless-stopped
18 +
19 +
volumes:
20 +
  cellar-data:
apps/cellar/src/auth.rs (added) +75 −0
1 +
use axum::{
2 +
    extract::FromRequestParts,
3 +
    http::request::Parts,
4 +
    response::{IntoResponse, Redirect, Response},
5 +
};
6 +
use std::sync::Arc;
7 +
8 +
use crate::db;
9 +
use crate::server::AppState;
10 +
11 +
pub use andromeda_auth::{
12 +
    build_session_cookie, clear_session_cookie, generate_session_token, verify_password,
13 +
};
14 +
15 +
pub struct AuthSession;
16 +
17 +
impl FromRequestParts<Arc<AppState>> for AuthSession {
18 +
    type Rejection = Response;
19 +
20 +
    async fn from_request_parts(
21 +
        parts: &mut Parts,
22 +
        state: &Arc<AppState>,
23 +
    ) -> Result<Self, Self::Rejection> {
24 +
        let token = andromeda_auth::extract_session_cookie(&parts.headers);
25 +
        if let Some(token) = token {
26 +
            if is_valid_session(state, &token) {
27 +
                return Ok(AuthSession);
28 +
            }
29 +
        }
30 +
        Err(Redirect::to("/admin/login").into_response())
31 +
    }
32 +
}
33 +
34 +
fn is_valid_session(state: &AppState, token: &str) -> bool {
35 +
    match db::get_session_expiry(&state.db, token) {
36 +
        Ok(Some(expires_at)) => {
37 +
            let now = chrono_now();
38 +
            expires_at > now
39 +
        }
40 +
        _ => false,
41 +
    }
42 +
}
43 +
44 +
pub fn chrono_now() -> String {
45 +
    use std::time::{SystemTime, UNIX_EPOCH};
46 +
    let secs = SystemTime::now()
47 +
        .duration_since(UNIX_EPOCH)
48 +
        .unwrap()
49 +
        .as_secs();
50 +
    let days_since_epoch = secs / 86400;
51 +
    let time_of_day = secs % 86400;
52 +
    let hours = time_of_day / 3600;
53 +
    let minutes = (time_of_day % 3600) / 60;
54 +
    let seconds = time_of_day % 60;
55 +
56 +
    let (year, month, day) = days_to_ymd(days_since_epoch as i64);
57 +
    format!(
58 +
        "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
59 +
        year, month, day, hours, minutes, seconds
60 +
    )
61 +
}
62 +
63 +
fn days_to_ymd(mut days: i64) -> (i64, i64, i64) {
64 +
    days += 719468;
65 +
    let era = if days >= 0 { days } else { days - 146096 } / 146097;
66 +
    let doe = (days - era * 146097) as u32;
67 +
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
68 +
    let y = yoe as i64 + era * 400;
69 +
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
70 +
    let mp = (5 * doy + 2) / 153;
71 +
    let d = doy - (153 * mp + 2) / 5 + 1;
72 +
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
73 +
    let y = if m <= 2 { y + 1 } else { y };
74 +
    (y, m as i64, d as i64)
75 +
}
apps/cellar/src/claude.rs (added) +120 −0
1 +
use base64::Engine;
2 +
use base64::engine::general_purpose::STANDARD;
3 +
use serde::{Deserialize, Serialize};
4 +
5 +
#[derive(Serialize)]
6 +
struct ClaudeRequest {
7 +
    model: String,
8 +
    max_tokens: u32,
9 +
    messages: Vec<ClaudeMessage>,
10 +
}
11 +
12 +
#[derive(Serialize)]
13 +
struct ClaudeMessage {
14 +
    role: String,
15 +
    content: Vec<ClaudeContent>,
16 +
}
17 +
18 +
#[derive(Serialize)]
19 +
#[serde(tag = "type")]
20 +
enum ClaudeContent {
21 +
    #[serde(rename = "image")]
22 +
    Image { source: ImageSource },
23 +
    #[serde(rename = "text")]
24 +
    Text { text: String },
25 +
}
26 +
27 +
#[derive(Serialize)]
28 +
struct ImageSource {
29 +
    #[serde(rename = "type")]
30 +
    source_type: String,
31 +
    media_type: String,
32 +
    data: String,
33 +
}
34 +
35 +
#[derive(Deserialize, Serialize)]
36 +
pub struct AnalyzeResult {
37 +
    pub name: String,
38 +
    pub origin: String,
39 +
    pub grape: String,
40 +
    pub background: String,
41 +
}
42 +
43 +
#[derive(Deserialize)]
44 +
struct ClaudeResponse {
45 +
    content: Vec<ContentBlock>,
46 +
}
47 +
48 +
#[derive(Deserialize)]
49 +
struct ContentBlock {
50 +
    text: Option<String>,
51 +
}
52 +
53 +
pub async fn analyze_wine_image(
54 +
    api_key: &str,
55 +
    image_bytes: &[u8],
56 +
    media_type: &str,
57 +
) -> Result<AnalyzeResult, String> {
58 +
    let encoded = STANDARD.encode(image_bytes);
59 +
60 +
    let request = ClaudeRequest {
61 +
        model: "claude-sonnet-4-20250514".to_string(),
62 +
        max_tokens: 1024,
63 +
        messages: vec![ClaudeMessage {
64 +
            role: "user".to_string(),
65 +
            content: vec![
66 +
                ClaudeContent::Image {
67 +
                    source: ImageSource {
68 +
                        source_type: "base64".to_string(),
69 +
                        media_type: media_type.to_string(),
70 +
                        data: encoded,
71 +
                    },
72 +
                },
73 +
                ClaudeContent::Text {
74 +
                    text: "Look at this wine bottle label. Return a JSON object with exactly these fields: {\"name\": \"the full wine name\", \"origin\": \"region and/or country\", \"grape\": \"grape variety or blend\", \"background\": \"brief background about the wine and the winery, including any notable history or interesting facts\"}. If you cannot determine a field, use an empty string. Respond with ONLY the JSON, no other text.".to_string(),
75 +
                },
76 +
            ],
77 +
        }],
78 +
    };
79 +
80 +
    let client = reqwest::Client::new();
81 +
    let response = client
82 +
        .post("https://api.anthropic.com/v1/messages")
83 +
        .header("x-api-key", api_key)
84 +
        .header("anthropic-version", "2023-06-01")
85 +
        .header("content-type", "application/json")
86 +
        .json(&request)
87 +
        .send()
88 +
        .await
89 +
        .map_err(|e| format!("Request failed: {}", e))?;
90 +
91 +
    if !response.status().is_success() {
92 +
        let status = response.status();
93 +
        let body = response.text().await.unwrap_or_default();
94 +
        return Err(format!("API error {}: {}", status, body));
95 +
    }
96 +
97 +
    let claude_response: ClaudeResponse = response
98 +
        .json()
99 +
        .await
100 +
        .map_err(|e| format!("Failed to parse response: {}", e))?;
101 +
102 +
    let text = claude_response
103 +
        .content
104 +
        .iter()
105 +
        .find_map(|block| block.text.as_ref())
106 +
        .ok_or_else(|| "No text in response".to_string())?;
107 +
108 +
    let text = text.trim();
109 +
    let json_str = if let Some(start) = text.find('{') {
110 +
        if let Some(end) = text.rfind('}') {
111 +
            &text[start..=end]
112 +
        } else {
113 +
            text
114 +
        }
115 +
    } else {
116 +
        text
117 +
    };
118 +
119 +
    serde_json::from_str(json_str).map_err(|e| format!("Failed to parse JSON: {} (raw: {})", e, text))
120 +
}
apps/cellar/src/db.rs (added) +282 −0
1 +
use nanoid::nanoid;
2 +
use rusqlite::{Connection, params};
3 +
use serde::{Deserialize, Serialize};
4 +
use std::fmt;
5 +
use std::sync::{Arc, Mutex};
6 +
7 +
pub type Db = Arc<Mutex<Connection>>;
8 +
9 +
#[derive(Debug)]
10 +
pub enum DbError {
11 +
    Sqlite(rusqlite::Error),
12 +
    LockPoisoned,
13 +
}
14 +
15 +
impl fmt::Display for DbError {
16 +
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
17 +
        match self {
18 +
            DbError::Sqlite(e) => write!(f, "Database error: {}", e),
19 +
            DbError::LockPoisoned => write!(f, "Database lock poisoned"),
20 +
        }
21 +
    }
22 +
}
23 +
24 +
impl std::error::Error for DbError {}
25 +
26 +
impl From<rusqlite::Error> for DbError {
27 +
    fn from(e: rusqlite::Error) -> Self {
28 +
        DbError::Sqlite(e)
29 +
    }
30 +
}
31 +
32 +
#[derive(Debug, Serialize, Deserialize, Clone)]
33 +
pub struct Wine {
34 +
    pub id: i64,
35 +
    pub short_id: String,
36 +
    pub name: String,
37 +
    pub origin: String,
38 +
    pub grape: String,
39 +
    pub notes: String,
40 +
    pub has_image: bool,
41 +
    pub image_mime: Option<String>,
42 +
    pub sweetness: i32,
43 +
    pub acidity: i32,
44 +
    pub tannin: i32,
45 +
    pub alcohol: i32,
46 +
    pub body: i32,
47 +
    pub background: String,
48 +
    pub created_at: String,
49 +
}
50 +
51 +
pub fn init_db() -> Db {
52 +
    let path = std::env::var("CELLAR_DB_PATH").unwrap_or_else(|_| "cellar.sqlite".to_string());
53 +
    let conn = Connection::open(&path).expect("Failed to open database");
54 +
55 +
    conn.execute_batch(
56 +
        "CREATE TABLE IF NOT EXISTS wines (
57 +
            id         INTEGER PRIMARY KEY AUTOINCREMENT,
58 +
            short_id   TEXT NOT NULL UNIQUE,
59 +
            name       TEXT NOT NULL,
60 +
            origin     TEXT NOT NULL,
61 +
            grape      TEXT NOT NULL,
62 +
            notes      TEXT NOT NULL,
63 +
            image      BLOB,
64 +
            image_mime TEXT,
65 +
            sweetness  INTEGER NOT NULL CHECK(sweetness BETWEEN 1 AND 5),
66 +
            acidity    INTEGER NOT NULL CHECK(acidity BETWEEN 1 AND 5),
67 +
            tannin     INTEGER NOT NULL CHECK(tannin BETWEEN 1 AND 5),
68 +
            alcohol    INTEGER NOT NULL CHECK(alcohol BETWEEN 1 AND 5),
69 +
            body       INTEGER NOT NULL CHECK(body BETWEEN 1 AND 5),
70 +
            created_at TEXT NOT NULL DEFAULT (datetime('now'))
71 +
        );
72 +
73 +
        CREATE TABLE IF NOT EXISTS sessions (
74 +
            id         INTEGER PRIMARY KEY AUTOINCREMENT,
75 +
            token      TEXT NOT NULL UNIQUE,
76 +
            expires_at TEXT NOT NULL
77 +
        );"
78 +
    )
79 +
    .expect("Failed to create tables");
80 +
81 +
    // Migration: add background column if it doesn't exist
82 +
    let _ = conn.execute("ALTER TABLE wines ADD COLUMN background TEXT NOT NULL DEFAULT ''", []);
83 +
84 +
    Arc::new(Mutex::new(conn))
85 +
}
86 +
87 +
fn wine_from_row(row: &rusqlite::Row) -> rusqlite::Result<Wine> {
88 +
    Ok(Wine {
89 +
        id: row.get(0)?,
90 +
        short_id: row.get(1)?,
91 +
        name: row.get(2)?,
92 +
        origin: row.get(3)?,
93 +
        grape: row.get(4)?,
94 +
        notes: row.get(5)?,
95 +
        has_image: row.get(6)?,
96 +
        image_mime: row.get(7)?,
97 +
        sweetness: row.get(8)?,
98 +
        acidity: row.get(9)?,
99 +
        tannin: row.get(10)?,
100 +
        alcohol: row.get(11)?,
101 +
        body: row.get(12)?,
102 +
        background: row.get(13)?,
103 +
        created_at: row.get(14)?,
104 +
    })
105 +
}
106 +
107 +
const WINE_COLUMNS: &str =
108 +
    "id, short_id, name, origin, grape, notes, (image IS NOT NULL) AS has_image, image_mime, sweetness, acidity, tannin, alcohol, body, background, created_at";
109 +
110 +
pub fn create_wine(
111 +
    db: &Db,
112 +
    name: &str,
113 +
    origin: &str,
114 +
    grape: &str,
115 +
    notes: &str,
116 +
    image: Option<&[u8]>,
117 +
    image_mime: Option<&str>,
118 +
    sweetness: i32,
119 +
    acidity: i32,
120 +
    tannin: i32,
121 +
    alcohol: i32,
122 +
    body: i32,
123 +
    background: &str,
124 +
) -> Result<Wine, DbError> {
125 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
126 +
    let short_id = nanoid!(10);
127 +
    conn.execute(
128 +
        "INSERT INTO wines (short_id, name, origin, grape, notes, image, image_mime, sweetness, acidity, tannin, alcohol, body, background)
129 +
         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
130 +
        params![short_id, name, origin, grape, notes, image, image_mime, sweetness, acidity, tannin, alcohol, body, background],
131 +
    )?;
132 +
    let id = conn.last_insert_rowid();
133 +
    let wine = conn.query_row(
134 +
        &format!("SELECT {} FROM wines WHERE id = ?1", WINE_COLUMNS),
135 +
        params![id],
136 +
        wine_from_row,
137 +
    )?;
138 +
    Ok(wine)
139 +
}
140 +
141 +
pub fn get_all_wines(db: &Db) -> Result<Vec<Wine>, DbError> {
142 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
143 +
    let mut stmt = conn.prepare(&format!(
144 +
        "SELECT {} FROM wines ORDER BY id DESC",
145 +
        WINE_COLUMNS
146 +
    ))?;
147 +
    let wines = stmt
148 +
        .query_map([], wine_from_row)?
149 +
        .collect::<Result<Vec<_>, _>>()?;
150 +
    Ok(wines)
151 +
}
152 +
153 +
pub fn get_wine_by_short_id(db: &Db, short_id: &str) -> Result<Option<Wine>, DbError> {
154 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
155 +
    match conn.query_row(
156 +
        &format!(
157 +
            "SELECT {} FROM wines WHERE short_id = ?1",
158 +
            WINE_COLUMNS
159 +
        ),
160 +
        params![short_id],
161 +
        wine_from_row,
162 +
    ) {
163 +
        Ok(wine) => Ok(Some(wine)),
164 +
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
165 +
        Err(e) => Err(DbError::Sqlite(e)),
166 +
    }
167 +
}
168 +
169 +
pub fn get_wine_image(db: &Db, short_id: &str) -> Result<Option<(Vec<u8>, String)>, DbError> {
170 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
171 +
    match conn.query_row(
172 +
        "SELECT image, image_mime FROM wines WHERE short_id = ?1 AND image IS NOT NULL",
173 +
        params![short_id],
174 +
        |row| {
175 +
            let image: Vec<u8> = row.get(0)?;
176 +
            let mime: String = row.get(1)?;
177 +
            Ok((image, mime))
178 +
        },
179 +
    ) {
180 +
        Ok(result) => Ok(Some(result)),
181 +
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
182 +
        Err(e) => Err(DbError::Sqlite(e)),
183 +
    }
184 +
}
185 +
186 +
pub fn update_wine(
187 +
    db: &Db,
188 +
    short_id: &str,
189 +
    name: &str,
190 +
    origin: &str,
191 +
    grape: &str,
192 +
    notes: &str,
193 +
    sweetness: i32,
194 +
    acidity: i32,
195 +
    tannin: i32,
196 +
    alcohol: i32,
197 +
    body: i32,
198 +
    background: &str,
199 +
) -> Result<Option<Wine>, DbError> {
200 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
201 +
    let rows = conn.execute(
202 +
        "UPDATE wines SET name = ?1, origin = ?2, grape = ?3, notes = ?4, sweetness = ?5, acidity = ?6, tannin = ?7, alcohol = ?8, body = ?9, background = ?10 WHERE short_id = ?11",
203 +
        params![name, origin, grape, notes, sweetness, acidity, tannin, alcohol, body, background, short_id],
204 +
    )?;
205 +
    if rows == 0 {
206 +
        return Ok(None);
207 +
    }
208 +
    match conn.query_row(
209 +
        &format!(
210 +
            "SELECT {} FROM wines WHERE short_id = ?1",
211 +
            WINE_COLUMNS
212 +
        ),
213 +
        params![short_id],
214 +
        wine_from_row,
215 +
    ) {
216 +
        Ok(wine) => Ok(Some(wine)),
217 +
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
218 +
        Err(e) => Err(DbError::Sqlite(e)),
219 +
    }
220 +
}
221 +
222 +
pub fn update_wine_image(
223 +
    db: &Db,
224 +
    short_id: &str,
225 +
    image: &[u8],
226 +
    mime: &str,
227 +
) -> Result<bool, DbError> {
228 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
229 +
    let rows = conn.execute(
230 +
        "UPDATE wines SET image = ?1, image_mime = ?2 WHERE short_id = ?3",
231 +
        params![image, mime, short_id],
232 +
    )?;
233 +
    Ok(rows > 0)
234 +
}
235 +
236 +
pub fn delete_wine(db: &Db, short_id: &str) -> Result<bool, DbError> {
237 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
238 +
    let rows = conn.execute(
239 +
        "DELETE FROM wines WHERE short_id = ?1",
240 +
        params![short_id],
241 +
    )?;
242 +
    Ok(rows > 0)
243 +
}
244 +
245 +
// Session functions
246 +
247 +
pub fn insert_session(db: &Db, token: &str, expires_at: &str) -> Result<(), DbError> {
248 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
249 +
    conn.execute(
250 +
        "INSERT INTO sessions (token, expires_at) VALUES (?1, ?2)",
251 +
        params![token, expires_at],
252 +
    )?;
253 +
    Ok(())
254 +
}
255 +
256 +
pub fn get_session_expiry(db: &Db, token: &str) -> Result<Option<String>, DbError> {
257 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
258 +
    match conn.query_row(
259 +
        "SELECT expires_at FROM sessions WHERE token = ?1",
260 +
        params![token],
261 +
        |row| row.get(0),
262 +
    ) {
263 +
        Ok(val) => Ok(Some(val)),
264 +
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
265 +
        Err(e) => Err(DbError::Sqlite(e)),
266 +
    }
267 +
}
268 +
269 +
pub fn delete_session(db: &Db, token: &str) -> Result<(), DbError> {
270 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
271 +
    conn.execute("DELETE FROM sessions WHERE token = ?1", params![token])?;
272 +
    Ok(())
273 +
}
274 +
275 +
pub fn prune_expired_sessions(db: &Db) -> Result<(), DbError> {
276 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
277 +
    conn.execute(
278 +
        "DELETE FROM sessions WHERE expires_at < datetime('now')",
279 +
        [],
280 +
    )?;
281 +
    Ok(())
282 +
}
apps/cellar/src/main.rs (added) +15 −0
1 +
mod auth;
2 +
mod claude;
3 +
mod db;
4 +
mod server;
5 +
6 +
#[tokio::main]
7 +
async fn main() {
8 +
    tracing_subscriber::fmt::init();
9 +
    let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
10 +
    let port: u16 = std::env::var("PORT")
11 +
        .ok()
12 +
        .and_then(|v| v.parse().ok())
13 +
        .unwrap_or(3000);
14 +
    server::run(host, port).await;
15 +
}
apps/cellar/src/server.rs (added) +727 −0
1 +
use askama::Template;
2 +
use askama_web::WebTemplate;
3 +
use axum::{
4 +
    extract::{DefaultBodyLimit, Multipart, Path, Query, State},
5 +
    http::{HeaderValue, StatusCode},
6 +
    response::{Html, IntoResponse, Json, Redirect, Response},
7 +
    routing::{get, post},
8 +
    Router,
9 +
};
10 +
use rust_embed::Embed;
11 +
use std::sync::Arc;
12 +
13 +
use crate::auth;
14 +
use crate::claude;
15 +
use crate::db::{self, Db, Wine};
16 +
17 +
#[derive(Clone)]
18 +
pub struct AppState {
19 +
    pub db: Db,
20 +
    pub app_password: String,
21 +
    pub cookie_secure: bool,
22 +
    pub anthropic_api_key: Option<String>,
23 +
}
24 +
25 +
#[derive(Embed)]
26 +
#[folder = "static/"]
27 +
struct Static;
28 +
29 +
// --- Templates ---
30 +
31 +
#[derive(Template)]
32 +
#[template(path = "base.html")]
33 +
struct BaseTemplate;
34 +
35 +
#[derive(Template)]
36 +
#[template(path = "login.html")]
37 +
struct LoginTemplate {
38 +
    error: Option<String>,
39 +
}
40 +
41 +
struct WineWithSvg {
42 +
    wine: Wine,
43 +
    pentagon_svg: String,
44 +
}
45 +
46 +
#[derive(Template)]
47 +
#[template(path = "index.html")]
48 +
struct IndexTemplate {
49 +
    wines: Vec<WineWithSvg>,
50 +
}
51 +
52 +
#[derive(Template)]
53 +
#[template(path = "wine.html")]
54 +
struct WineDetailTemplate {
55 +
    wine: Wine,
56 +
    pentagon_svg: String,
57 +
}
58 +
59 +
#[derive(Template)]
60 +
#[template(path = "admin.html")]
61 +
struct AdminTemplate {
62 +
    wines: Vec<Wine>,
63 +
}
64 +
65 +
#[derive(Template)]
66 +
#[template(path = "wine_form.html")]
67 +
struct WineFormTemplate {
68 +
    wine: Option<Wine>,
69 +
    error: Option<String>,
70 +
    has_anthropic_key: bool,
71 +
}
72 +
73 +
// --- Query/Form structs ---
74 +
75 +
#[derive(serde::Deserialize, Default)]
76 +
pub struct FlashQuery {
77 +
    pub error: Option<String>,
78 +
}
79 +
80 +
#[derive(serde::Deserialize)]
81 +
struct LoginForm {
82 +
    password: String,
83 +
}
84 +
85 +
// --- Static file handlers ---
86 +
87 +
fn mime_from_path(path: &str) -> &'static str {
88 +
    match path.rsplit('.').next().unwrap_or("") {
89 +
        "css" => "text/css",
90 +
        "js" => "application/javascript",
91 +
        "html" => "text/html",
92 +
        "png" => "image/png",
93 +
        "jpg" | "jpeg" => "image/jpeg",
94 +
        "ico" => "image/x-icon",
95 +
        "svg" => "image/svg+xml",
96 +
        "woff" | "woff2" => "font/woff2",
97 +
        "ttf" => "font/ttf",
98 +
        "otf" => "font/otf",
99 +
        "json" | "webmanifest" => "application/json",
100 +
        _ => "application/octet-stream",
101 +
    }
102 +
}
103 +
104 +
async fn serve_static(Path(path): Path<String>) -> Response {
105 +
    match Static::get(&path) {
106 +
        Some(file) => {
107 +
            let mime = mime_from_path(&path);
108 +
            (
109 +
                StatusCode::OK,
110 +
                [(axum::http::header::CONTENT_TYPE, HeaderValue::from_static(mime))],
111 +
                file.data.to_vec(),
112 +
            )
113 +
                .into_response()
114 +
        }
115 +
        None => StatusCode::NOT_FOUND.into_response(),
116 +
    }
117 +
}
118 +
119 +
// --- Pentagon SVG ---
120 +
121 +
fn build_pentagon_svg(
122 +
    sweetness: i32,
123 +
    acidity: i32,
124 +
    tannin: i32,
125 +
    alcohol: i32,
126 +
    body: i32,
127 +
    size: f64,
128 +
    show_labels: bool,
129 +
) -> String {
130 +
    let cx = size / 2.0;
131 +
    let cy = size / 2.0;
132 +
    let margin = if show_labels { 30.0 } else { 5.0 };
133 +
    let r = size / 2.0 - margin;
134 +
135 +
    let scores = [sweetness, acidity, tannin, alcohol, body];
136 +
    let labels = ["Sweetness", "Acidity", "Tannin", "Alcohol", "Body"];
137 +
138 +
    let angles: Vec<f64> = (0..5)
139 +
        .map(|i| (-90.0_f64 + 72.0 * i as f64).to_radians())
140 +
        .collect();
141 +
142 +
    let mut svg = format!(
143 +
        r#"<svg viewBox="0 0 {s} {s}" width="{s}" height="{s}" xmlns="http://www.w3.org/2000/svg">"#,
144 +
        s = size
145 +
    );
146 +
147 +
    // Grid pentagons at 20%, 40%, 60%, 80%
148 +
    for pct in &[0.2, 0.4, 0.6, 0.8] {
149 +
        let points: String = angles
150 +
            .iter()
151 +
            .map(|a| format!("{:.1},{:.1}", cx + r * pct * a.cos(), cy + r * pct * a.sin()))
152 +
            .collect::<Vec<_>>()
153 +
            .join(" ");
154 +
        svg.push_str(&format!(
155 +
            r#"<polygon points="{}" fill="none" stroke="white" stroke-opacity="0.12" stroke-width="0.75"/>"#,
156 +
            points
157 +
        ));
158 +
    }
159 +
160 +
    // Outer pentagon (100%)
161 +
    let outline: String = angles
162 +
        .iter()
163 +
        .map(|a| format!("{:.1},{:.1}", cx + r * a.cos(), cy + r * a.sin()))
164 +
        .collect::<Vec<_>>()
165 +
        .join(" ");
166 +
    svg.push_str(&format!(
167 +
        r#"<polygon points="{}" fill="none" stroke="white" stroke-opacity="0.25" stroke-width="1"/>"#,
168 +
        outline
169 +
    ));
170 +
171 +
    // Axis lines from center to each vertex
172 +
    for a in &angles {
173 +
        svg.push_str(&format!(
174 +
            r#"<line x1="{:.1}" y1="{:.1}" x2="{:.1}" y2="{:.1}" stroke="white" stroke-opacity="0.12" stroke-width="0.75"/>"#,
175 +
            cx, cy, cx + r * a.cos(), cy + r * a.sin()
176 +
        ));
177 +
    }
178 +
179 +
    // Data polygon
180 +
    let data_points: Vec<(f64, f64)> = scores
181 +
        .iter()
182 +
        .zip(&angles)
183 +
        .map(|(s, a)| {
184 +
            let d = (*s as f64 / 5.0) * r;
185 +
            (cx + d * a.cos(), cy + d * a.sin())
186 +
        })
187 +
        .collect();
188 +
189 +
    let data_str: String = data_points
190 +
        .iter()
191 +
        .map(|(x, y)| format!("{:.1},{:.1}", x, y))
192 +
        .collect::<Vec<_>>()
193 +
        .join(" ");
194 +
    svg.push_str(&format!(
195 +
        r#"<polygon points="{}" fill="white" fill-opacity="0.08" stroke="white" stroke-width="1.5"/>"#,
196 +
        data_str
197 +
    ));
198 +
199 +
    // Data dots
200 +
    for (x, y) in &data_points {
201 +
        svg.push_str(&format!(
202 +
            r#"<circle cx="{:.1}" cy="{:.1}" r="2.5" fill="white"/>"#,
203 +
            x, y
204 +
        ));
205 +
    }
206 +
207 +
    // Labels
208 +
    if show_labels {
209 +
        for (i, label) in labels.iter().enumerate() {
210 +
            let a = angles[i];
211 +
            let label_dist = r + 18.0;
212 +
            let lx = cx + label_dist * a.cos();
213 +
            let ly = cy + label_dist * a.sin() + 3.5;
214 +
            svg.push_str(&format!(
215 +
                r#"<text x="{:.1}" y="{:.1}" fill="white" fill-opacity="0.5" font-size="9" font-family="Commit Mono, monospace" text-anchor="middle">{}</text>"#,
216 +
                lx, ly, label
217 +
            ));
218 +
        }
219 +
    }
220 +
221 +
    svg.push_str("</svg>");
222 +
    svg
223 +
}
224 +
225 +
// --- Auth handlers ---
226 +
227 +
async fn get_login(Query(q): Query<FlashQuery>) -> Response {
228 +
    WebTemplate(LoginTemplate { error: q.error }).into_response()
229 +
}
230 +
231 +
async fn post_login(
232 +
    State(state): State<Arc<AppState>>,
233 +
    axum::extract::Form(form): axum::extract::Form<LoginForm>,
234 +
) -> Response {
235 +
    if !auth::verify_password(&form.password, &state.app_password) {
236 +
        return Redirect::to("/admin/login?error=Invalid+password").into_response();
237 +
    }
238 +
239 +
    let token = auth::generate_session_token();
240 +
241 +
    let expires_at = {
242 +
        use std::time::{SystemTime, UNIX_EPOCH};
243 +
        let secs = SystemTime::now()
244 +
            .duration_since(UNIX_EPOCH)
245 +
            .unwrap()
246 +
            .as_secs()
247 +
            + 7 * 24 * 3600;
248 +
        let days = secs / 86400;
249 +
        let tod = secs % 86400;
250 +
        let (y, m, d) = days_to_ymd(days as i64);
251 +
        format!(
252 +
            "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
253 +
            y,
254 +
            m,
255 +
            d,
256 +
            tod / 3600,
257 +
            (tod % 3600) / 60,
258 +
            tod % 60
259 +
        )
260 +
    };
261 +
262 +
    if let Err(e) = db::insert_session(&state.db, &token, &expires_at) {
263 +
        tracing::error!("Failed to create session: {}", e);
264 +
        return Redirect::to("/admin/login?error=Server+error").into_response();
265 +
    }
266 +
267 +
    let _ = db::prune_expired_sessions(&state.db);
268 +
269 +
    let cookie = auth::build_session_cookie(&token, state.cookie_secure);
270 +
    let mut resp = Redirect::to("/admin").into_response();
271 +
    resp.headers_mut().insert(
272 +
        axum::http::header::SET_COOKIE,
273 +
        HeaderValue::from_str(&cookie).unwrap(),
274 +
    );
275 +
    resp
276 +
}
277 +
278 +
async fn get_logout(State(state): State<Arc<AppState>>, headers: axum::http::HeaderMap) -> Response {
279 +
    if let Some(cookie_header) = headers.get("cookie").and_then(|v| v.to_str().ok()) {
280 +
        for part in cookie_header.split(';') {
281 +
            let part = part.trim();
282 +
            if let Some(val) = part.strip_prefix("session=") {
283 +
                let val = val.trim();
284 +
                if !val.is_empty() {
285 +
                    let _ = db::delete_session(&state.db, val);
286 +
                }
287 +
            }
288 +
        }
289 +
    }
290 +
291 +
    let cookie = auth::clear_session_cookie();
292 +
    let mut resp = Redirect::to("/admin/login").into_response();
293 +
    resp.headers_mut().insert(
294 +
        axum::http::header::SET_COOKIE,
295 +
        HeaderValue::from_str(&cookie).unwrap(),
296 +
    );
297 +
    resp
298 +
}
299 +
300 +
// --- Public handlers ---
301 +
302 +
async fn get_index(State(state): State<Arc<AppState>>) -> Response {
303 +
    match db::get_all_wines(&state.db) {
304 +
        Ok(wines) => {
305 +
            let wines: Vec<WineWithSvg> = wines
306 +
                .into_iter()
307 +
                .map(|wine| {
308 +
                    let pentagon_svg = build_pentagon_svg(
309 +
                        wine.sweetness,
310 +
                        wine.acidity,
311 +
                        wine.tannin,
312 +
                        wine.alcohol,
313 +
                        wine.body,
314 +
                        80.0,
315 +
                        false,
316 +
                    );
317 +
                    WineWithSvg { wine, pentagon_svg }
318 +
                })
319 +
                .collect();
320 +
            WebTemplate(IndexTemplate { wines }).into_response()
321 +
        }
322 +
        Err(e) => {
323 +
            tracing::error!("Failed to list wines: {}", e);
324 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
325 +
        }
326 +
    }
327 +
}
328 +
329 +
async fn get_wine_detail(
330 +
    State(state): State<Arc<AppState>>,
331 +
    Path(short_id): Path<String>,
332 +
) -> Response {
333 +
    match db::get_wine_by_short_id(&state.db, &short_id) {
334 +
        Ok(Some(wine)) => {
335 +
            let pentagon_svg = build_pentagon_svg(
336 +
                wine.sweetness,
337 +
                wine.acidity,
338 +
                wine.tannin,
339 +
                wine.alcohol,
340 +
                wine.body,
341 +
                250.0,
342 +
                true,
343 +
            );
344 +
            WebTemplate(WineDetailTemplate { wine, pentagon_svg }).into_response()
345 +
        }
346 +
        Ok(None) => (StatusCode::NOT_FOUND, Html("Wine not found".to_string())).into_response(),
347 +
        Err(e) => {
348 +
            tracing::error!("Failed to get wine: {}", e);
349 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
350 +
        }
351 +
    }
352 +
}
353 +
354 +
async fn get_wine_image(
355 +
    State(state): State<Arc<AppState>>,
356 +
    Path(short_id): Path<String>,
357 +
) -> Response {
358 +
    match db::get_wine_image(&state.db, &short_id) {
359 +
        Ok(Some((bytes, mime))) => {
360 +
            let content_type = HeaderValue::from_str(&mime).unwrap_or_else(|_| {
361 +
                HeaderValue::from_static("application/octet-stream")
362 +
            });
363 +
            (
364 +
                StatusCode::OK,
365 +
                [(axum::http::header::CONTENT_TYPE, content_type)],
366 +
                bytes,
367 +
            )
368 +
                .into_response()
369 +
        }
370 +
        Ok(None) => StatusCode::NOT_FOUND.into_response(),
371 +
        Err(e) => {
372 +
            tracing::error!("Failed to get wine image: {}", e);
373 +
            StatusCode::INTERNAL_SERVER_ERROR.into_response()
374 +
        }
375 +
    }
376 +
}
377 +
378 +
// --- Admin handlers ---
379 +
380 +
async fn get_admin(
381 +
    _session: auth::AuthSession,
382 +
    State(state): State<Arc<AppState>>,
383 +
) -> Response {
384 +
    match db::get_all_wines(&state.db) {
385 +
        Ok(wines) => WebTemplate(AdminTemplate { wines }).into_response(),
386 +
        Err(e) => {
387 +
            tracing::error!("Failed to list wines: {}", e);
388 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
389 +
        }
390 +
    }
391 +
}
392 +
393 +
async fn get_new_wine(
394 +
    _session: auth::AuthSession,
395 +
    State(state): State<Arc<AppState>>,
396 +
    Query(q): Query<FlashQuery>,
397 +
) -> Response {
398 +
    WebTemplate(WineFormTemplate {
399 +
        wine: None,
400 +
        error: q.error,
401 +
        has_anthropic_key: state.anthropic_api_key.is_some(),
402 +
    })
403 +
    .into_response()
404 +
}
405 +
406 +
async fn get_edit_wine(
407 +
    _session: auth::AuthSession,
408 +
    State(state): State<Arc<AppState>>,
409 +
    Path(short_id): Path<String>,
410 +
    Query(q): Query<FlashQuery>,
411 +
) -> Response {
412 +
    match db::get_wine_by_short_id(&state.db, &short_id) {
413 +
        Ok(Some(wine)) => WebTemplate(WineFormTemplate {
414 +
            wine: Some(wine),
415 +
            error: q.error,
416 +
            has_anthropic_key: state.anthropic_api_key.is_some(),
417 +
        })
418 +
        .into_response(),
419 +
        Ok(None) => (StatusCode::NOT_FOUND, Html("Wine not found".to_string())).into_response(),
420 +
        Err(e) => {
421 +
            tracing::error!("Failed to get wine: {}", e);
422 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
423 +
        }
424 +
    }
425 +
}
426 +
427 +
// --- Multipart parsing ---
428 +
429 +
struct WineFormData {
430 +
    name: String,
431 +
    origin: String,
432 +
    grape: String,
433 +
    notes: String,
434 +
    background: String,
435 +
    image: Option<Vec<u8>>,
436 +
    image_mime: Option<String>,
437 +
    sweetness: i32,
438 +
    acidity: i32,
439 +
    tannin: i32,
440 +
    alcohol: i32,
441 +
    body: i32,
442 +
}
443 +
444 +
async fn parse_wine_multipart(mut multipart: Multipart) -> Result<WineFormData, String> {
445 +
    let mut name = String::new();
446 +
    let mut origin = String::new();
447 +
    let mut grape = String::new();
448 +
    let mut notes = String::new();
449 +
    let mut background = String::new();
450 +
    let mut image: Option<Vec<u8>> = None;
451 +
    let mut image_mime: Option<String> = None;
452 +
    let mut sweetness = 3;
453 +
    let mut acidity = 3;
454 +
    let mut tannin = 3;
455 +
    let mut alcohol = 3;
456 +
    let mut body = 3;
457 +
458 +
    while let Ok(Some(field)) = multipart.next_field().await {
459 +
        let field_name = field.name().unwrap_or("").to_string();
460 +
        match field_name.as_str() {
461 +
            "image" => {
462 +
                let content_type = field.content_type().unwrap_or("application/octet-stream").to_string();
463 +
                let bytes = field.bytes().await.map_err(|e| format!("Failed to read image: {}", e))?;
464 +
                if !bytes.is_empty() {
465 +
                    image = Some(bytes.to_vec());
466 +
                    image_mime = Some(content_type);
467 +
                }
468 +
            }
469 +
            "name" => name = field.text().await.unwrap_or_default(),
470 +
            "origin" => origin = field.text().await.unwrap_or_default(),
471 +
            "grape" => grape = field.text().await.unwrap_or_default(),
472 +
            "notes" => notes = field.text().await.unwrap_or_default(),
473 +
            "background" => background = field.text().await.unwrap_or_default(),
474 +
            "sweetness" => sweetness = field.text().await.unwrap_or_default().parse().unwrap_or(3),
475 +
            "acidity" => acidity = field.text().await.unwrap_or_default().parse().unwrap_or(3),
476 +
            "tannin" => tannin = field.text().await.unwrap_or_default().parse().unwrap_or(3),
477 +
            "alcohol" => alcohol = field.text().await.unwrap_or_default().parse().unwrap_or(3),
478 +
            "body" => body = field.text().await.unwrap_or_default().parse().unwrap_or(3),
479 +
            _ => {}
480 +
        }
481 +
    }
482 +
483 +
    if name.trim().is_empty() {
484 +
        return Err("Name is required".to_string());
485 +
    }
486 +
487 +
    // Clamp scores to 1-5
488 +
    let clamp = |v: i32| v.max(1).min(5);
489 +
    Ok(WineFormData {
490 +
        name: name.trim().to_string(),
491 +
        origin: origin.trim().to_string(),
492 +
        grape: grape.trim().to_string(),
493 +
        notes: notes.trim().to_string(),
494 +
        background: background.trim().to_string(),
495 +
        image,
496 +
        image_mime,
497 +
        sweetness: clamp(sweetness),
498 +
        acidity: clamp(acidity),
499 +
        tannin: clamp(tannin),
500 +
        alcohol: clamp(alcohol),
501 +
        body: clamp(body),
502 +
    })
503 +
}
504 +
505 +
async fn post_new_wine(
506 +
    _session: auth::AuthSession,
507 +
    State(state): State<Arc<AppState>>,
508 +
    multipart: Multipart,
509 +
) -> Response {
510 +
    let data = match parse_wine_multipart(multipart).await {
511 +
        Ok(data) => data,
512 +
        Err(e) => {
513 +
            return Redirect::to(&format!("/admin/new?error={}", urlencoded(&e))).into_response();
514 +
        }
515 +
    };
516 +
517 +
    match db::create_wine(
518 +
        &state.db,
519 +
        &data.name,
520 +
        &data.origin,
521 +
        &data.grape,
522 +
        &data.notes,
523 +
        data.image.as_deref(),
524 +
        data.image_mime.as_deref(),
525 +
        data.sweetness,
526 +
        data.acidity,
527 +
        data.tannin,
528 +
        data.alcohol,
529 +
        data.body,
530 +
        &data.background,
531 +
    ) {
532 +
        Ok(wine) => Redirect::to(&format!("/wines/{}", wine.short_id)).into_response(),
533 +
        Err(e) => {
534 +
            tracing::error!("Failed to create wine: {}", e);
535 +
            Redirect::to("/admin/new?error=Failed+to+create+wine").into_response()
536 +
        }
537 +
    }
538 +
}
539 +
540 +
async fn post_edit_wine(
541 +
    _session: auth::AuthSession,
542 +
    State(state): State<Arc<AppState>>,
543 +
    Path(short_id): Path<String>,
544 +
    multipart: Multipart,
545 +
) -> Response {
546 +
    let data = match parse_wine_multipart(multipart).await {
547 +
        Ok(data) => data,
548 +
        Err(e) => {
549 +
            return Redirect::to(&format!("/admin/edit/{}?error={}", short_id, urlencoded(&e)))
550 +
                .into_response();
551 +
        }
552 +
    };
553 +
554 +
    match db::update_wine(
555 +
        &state.db,
556 +
        &short_id,
557 +
        &data.name,
558 +
        &data.origin,
559 +
        &data.grape,
560 +
        &data.notes,
561 +
        data.sweetness,
562 +
        data.acidity,
563 +
        data.tannin,
564 +
        data.alcohol,
565 +
        data.body,
566 +
        &data.background,
567 +
    ) {
568 +
        Ok(Some(_)) => {
569 +
            if let Some(image) = &data.image {
570 +
                if let Some(mime) = &data.image_mime {
571 +
                    if let Err(e) = db::update_wine_image(&state.db, &short_id, image, mime) {
572 +
                        tracing::error!("Failed to update wine image: {}", e);
573 +
                    }
574 +
                }
575 +
            }
576 +
            Redirect::to(&format!("/wines/{}", short_id)).into_response()
577 +
        }
578 +
        Ok(None) => (StatusCode::NOT_FOUND, Html("Wine not found".to_string())).into_response(),
579 +
        Err(e) => {
580 +
            tracing::error!("Failed to update wine: {}", e);
581 +
            Redirect::to(&format!(
582 +
                "/admin/edit/{}?error=Failed+to+update+wine",
583 +
                short_id
584 +
            ))
585 +
            .into_response()
586 +
        }
587 +
    }
588 +
}
589 +
590 +
async fn post_delete_wine(
591 +
    _session: auth::AuthSession,
592 +
    State(state): State<Arc<AppState>>,
593 +
    Path(short_id): Path<String>,
594 +
) -> Response {
595 +
    match db::delete_wine(&state.db, &short_id) {
596 +
        Ok(_) => Redirect::to("/admin").into_response(),
597 +
        Err(e) => {
598 +
            tracing::error!("Failed to delete wine: {}", e);
599 +
            Redirect::to("/admin").into_response()
600 +
        }
601 +
    }
602 +
}
603 +
604 +
// --- Claude vision handler ---
605 +
606 +
async fn post_analyze_image(
607 +
    _session: auth::AuthSession,
608 +
    State(state): State<Arc<AppState>>,
609 +
    mut multipart: Multipart,
610 +
) -> Response {
611 +
    let api_key = match &state.anthropic_api_key {
612 +
        Some(key) => key.clone(),
613 +
        None => {
614 +
            return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "No API key configured"})))
615 +
                .into_response();
616 +
        }
617 +
    };
618 +
619 +
    let mut image_bytes: Option<Vec<u8>> = None;
620 +
    let mut media_type = String::from("image/jpeg");
621 +
622 +
    while let Ok(Some(field)) = multipart.next_field().await {
623 +
        if field.name() == Some("image") {
624 +
            media_type = field.content_type().unwrap_or("image/jpeg").to_string();
625 +
            if let Ok(bytes) = field.bytes().await {
626 +
                if !bytes.is_empty() {
627 +
                    image_bytes = Some(bytes.to_vec());
628 +
                }
629 +
            }
630 +
        }
631 +
    }
632 +
633 +
    let image_bytes = match image_bytes {
634 +
        Some(bytes) => bytes,
635 +
        None => {
636 +
            return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "No image provided"})))
637 +
                .into_response();
638 +
        }
639 +
    };
640 +
641 +
    match claude::analyze_wine_image(&api_key, &image_bytes, &media_type).await {
642 +
        Ok(result) => (StatusCode::OK, Json(result)).into_response(),
643 +
        Err(e) => {
644 +
            tracing::error!("Claude analysis failed: {}", e);
645 +
            (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e})))
646 +
                .into_response()
647 +
        }
648 +
    }
649 +
}
650 +
651 +
// --- Helpers ---
652 +
653 +
fn urlencoded(s: &str) -> String {
654 +
    s.replace(' ', "+")
655 +
        .replace('&', "%26")
656 +
        .replace('=', "%3D")
657 +
}
658 +
659 +
fn days_to_ymd(mut days: i64) -> (i64, i64, i64) {
660 +
    days += 719468;
661 +
    let era = if days >= 0 { days } else { days - 146096 } / 146097;
662 +
    let doe = (days - era * 146097) as u32;
663 +
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
664 +
    let y = yoe as i64 + era * 400;
665 +
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
666 +
    let mp = (5 * doy + 2) / 153;
667 +
    let d = doy - (153 * mp + 2) / 5 + 1;
668 +
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
669 +
    let y = if m <= 2 { y + 1 } else { y };
670 +
    (y, m as i64, d as i64)
671 +
}
672 +
673 +
// --- Router ---
674 +
675 +
pub async fn run(host: String, port: u16) {
676 +
    dotenvy::dotenv().ok();
677 +
678 +
    let db = db::init_db();
679 +
680 +
    if let Err(e) = db::prune_expired_sessions(&db) {
681 +
        tracing::warn!("Failed to prune sessions: {}", e);
682 +
    }
683 +
684 +
    let app_password = std::env::var("CELLAR_PASSWORD").unwrap_or_else(|_| {
685 +
        tracing::warn!("CELLAR_PASSWORD not set, using default 'changeme'");
686 +
        "changeme".to_string()
687 +
    });
688 +
689 +
    let cookie_secure = std::env::var("COOKIE_SECURE")
690 +
        .map(|v| v == "true")
691 +
        .unwrap_or(false);
692 +
693 +
    let anthropic_api_key = std::env::var("ANTHROPIC_API_KEY").ok().filter(|k| !k.is_empty());
694 +
695 +
    let state = Arc::new(AppState {
696 +
        db,
697 +
        app_password,
698 +
        cookie_secure,
699 +
        anthropic_api_key,
700 +
    });
701 +
702 +
    let app = Router::new()
703 +
        // Public routes
704 +
        .route("/", get(get_index))
705 +
        .route("/wines/{short_id}", get(get_wine_detail))
706 +
        .route("/wines/{short_id}/image", get(get_wine_image))
707 +
        // Admin auth routes
708 +
        .route("/admin/login", get(get_login).post(post_login))
709 +
        .route("/admin/logout", get(get_logout))
710 +
        // Admin protected routes
711 +
        .route("/admin", get(get_admin))
712 +
        .route("/admin/new", get(get_new_wine).post(post_new_wine))
713 +
        .route("/admin/edit/{short_id}", get(get_edit_wine).post(post_edit_wine))
714 +
        .route("/admin/delete/{short_id}", post(post_delete_wine))
715 +
        // Claude vision
716 +
        .route("/admin/analyze-image", post(post_analyze_image))
717 +
        // Static assets
718 +
        .route("/static/{*path}", get(serve_static))
719 +
        .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
720 +
        .with_state(state);
721 +
722 +
    let addr = format!("{}:{}", host, port);
723 +
    tracing::info!("Listening on http://{}", addr);
724 +
725 +
    let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
726 +
    axum::serve(listener, app).await.unwrap();
727 +
}
apps/cellar/static/android-chrome-192x192.png (added) +0 −0

Binary file — no preview.

apps/cellar/static/android-chrome-512x512.png (added) +0 −0

Binary file — no preview.

apps/cellar/static/apple-touch-icon.png (added) +0 −0

Binary file — no preview.

apps/cellar/static/favicon-16x16.png (added) +0 −0

Binary file — no preview.

apps/cellar/static/favicon-32x32.png (added) +0 −0

Binary file — no preview.

apps/cellar/static/favicon.ico (added) +0 −0

Binary file — no preview.

apps/cellar/static/fonts/CommitMono-400-Regular.otf (added) +0 −0

Binary file — no preview.

apps/cellar/static/fonts/CommitMono-700-Regular.otf (added) +0 −0

Binary file — no preview.

apps/cellar/static/icon.png (added) +0 −0

Binary file — no preview.

apps/cellar/static/og.png (added) +0 −0

Binary file — no preview.

apps/cellar/static/site.webmanifest (added) +1 −0
1 +
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
apps/cellar/static/styles.css (added) +400 −0
1 +
@font-face {
2 +
  font-family: "Commit Mono";
3 +
  src: url("/static/fonts/CommitMono-400-Regular.otf") format("opentype");
4 +
  font-weight: 400;
5 +
  font-style: normal;
6 +
}
7 +
8 +
@font-face {
9 +
  font-family: "Commit Mono";
10 +
  src: url("/static/fonts/CommitMono-700-Regular.otf") format("opentype");
11 +
  font-weight: 700;
12 +
  font-style: normal;
13 +
}
14 +
15 +
* {
16 +
  padding: 0;
17 +
  margin: 0;
18 +
  box-sizing: border-box;
19 +
  font-family: "Commit Mono", monospace, sans-serif;
20 +
  scrollbar-width: none;
21 +
  -ms-overflow-style: none;
22 +
}
23 +
24 +
html {
25 +
  background: #121113;
26 +
  color: #ffffff;
27 +
  font-size: 14px;
28 +
  line-height: 1.6;
29 +
}
30 +
31 +
html::-webkit-scrollbar {
32 +
  display: none;
33 +
}
34 +
35 +
body {
36 +
  display: flex;
37 +
  flex-direction: column;
38 +
  justify-content: start;
39 +
  align-items: start;
40 +
  gap: 1.5rem;
41 +
  min-height: 100vh;
42 +
  max-width: 700px;
43 +
  margin: auto;
44 +
  padding: 0 1rem;
45 +
}
46 +
47 +
@media (max-width: 480px) {
48 +
  body {
49 +
    padding: 1rem;
50 +
    gap: 1rem;
51 +
  }
52 +
}
53 +
54 +
a {
55 +
  color: #ffffff;
56 +
  text-decoration: none;
57 +
}
58 +
59 +
a:hover {
60 +
  opacity: 0.7;
61 +
}
62 +
63 +
/* Header */
64 +
65 +
.header {
66 +
  display: flex;
67 +
  flex-direction: column;
68 +
  gap: 0.5rem;
69 +
  width: 100%;
70 +
  margin-top: 2rem;
71 +
  border-bottom: 1px solid #333;
72 +
  padding-bottom: 1rem;
73 +
}
74 +
75 +
.logo {
76 +
  font-size: 28px;
77 +
  font-weight: 700;
78 +
  text-decoration: none;
79 +
  text-transform: uppercase;
80 +
}
81 +
82 +
.links {
83 +
  display: flex;
84 +
  align-items: center;
85 +
  gap: 0.75rem;
86 +
  font-size: 12px;
87 +
}
88 +
89 +
/* Main content */
90 +
91 +
main {
92 +
  width: 100%;
93 +
  display: flex;
94 +
  flex-direction: column;
95 +
  gap: 1rem;
96 +
}
97 +
98 +
/* Forms */
99 +
100 +
.form {
101 +
  display: flex;
102 +
  flex-direction: column;
103 +
  gap: 0.5rem;
104 +
  width: 100%;
105 +
}
106 +
107 +
label {
108 +
  font-size: 12px;
109 +
  opacity: 0.7;
110 +
}
111 +
112 +
input, textarea {
113 +
  background: #121113;
114 +
  color: #ffffff;
115 +
  border: 1px solid white;
116 +
  padding: 0.4rem 0.75rem;
117 +
  font-size: 16px;
118 +
  width: 100%;
119 +
  border-radius: 0;
120 +
}
121 +
122 +
input[type="file"] {
123 +
  border: none;
124 +
  padding: 0;
125 +
  font-size: 12px;
126 +
}
127 +
128 +
textarea {
129 +
  min-height: 120px;
130 +
  resize: vertical;
131 +
}
132 +
133 +
button {
134 +
  background: #121113;
135 +
  color: #ffffff;
136 +
  padding: 0.4rem 0.75rem;
137 +
  border: 1px solid white;
138 +
  cursor: pointer;
139 +
  width: fit-content;
140 +
  font-size: 14px;
141 +
  border-radius: 0;
142 +
}
143 +
144 +
button:hover {
145 +
  opacity: 0.7;
146 +
}
147 +
148 +
button:disabled {
149 +
  opacity: 0.3;
150 +
  cursor: not-allowed;
151 +
}
152 +
153 +
/* Error */
154 +
155 +
.error {
156 +
  color: #ffffff;
157 +
  border-left: 2px solid #ffffff;
158 +
  padding-left: 0.5rem;
159 +
  font-size: 13px;
160 +
  opacity: 0.8;
161 +
}
162 +
163 +
.empty {
164 +
  opacity: 0.5;
165 +
  font-size: 12px;
166 +
}
167 +
168 +
/* Wine list (public) */
169 +
170 +
.wine-list {
171 +
  display: flex;
172 +
  flex-direction: column;
173 +
  width: 100%;
174 +
}
175 +
176 +
.wine-card {
177 +
  display: flex;
178 +
  align-items: center;
179 +
  gap: 1rem;
180 +
  padding: 12px 0;
181 +
  border-bottom: 1px solid #333;
182 +
  text-decoration: none;
183 +
}
184 +
185 +
.wine-card:hover {
186 +
  opacity: 0.7;
187 +
}
188 +
189 +
.wine-pentagon {
190 +
  flex-shrink: 0;
191 +
  width: 80px;
192 +
  height: 80px;
193 +
}
194 +
195 +
.wine-info {
196 +
  display: flex;
197 +
  flex-direction: column;
198 +
  gap: 2px;
199 +
}
200 +
201 +
.wine-name {
202 +
  font-size: 16px;
203 +
}
204 +
205 +
.wine-meta {
206 +
  font-size: 12px;
207 +
  opacity: 0.5;
208 +
}
209 +
210 +
/* Wine detail (public) */
211 +
212 +
.wine-detail {
213 +
  display: flex;
214 +
  flex-direction: column;
215 +
  gap: 1.5rem;
216 +
  width: 100%;
217 +
  padding-bottom: 4rem;
218 +
}
219 +
220 +
.wine-detail-top {
221 +
  display: grid;
222 +
  grid-template-columns: 1fr 1fr;
223 +
  gap: 1.5rem;
224 +
  align-items: center;
225 +
}
226 +
227 +
@media (max-width: 480px) {
228 +
  .wine-detail-top {
229 +
    grid-template-columns: 1fr;
230 +
  }
231 +
  .wine-image {
232 +
    max-height: none;
233 +
    width: 100%;
234 +
  }
235 +
}
236 +
237 +
.wine-image-wrap {
238 +
  width: 100%;
239 +
}
240 +
241 +
.wine-image {
242 +
  width: 100%;
243 +
  object-fit: cover;
244 +
  border-radius: 4px;
245 +
}
246 +
247 +
.wine-detail-name {
248 +
  font-size: 24px;
249 +
  font-weight: 700;
250 +
  letter-spacing: -0.5px;
251 +
}
252 +
253 +
.wine-detail-meta {
254 +
  display: flex;
255 +
  flex-direction: column;
256 +
  gap: 0.25rem;
257 +
}
258 +
259 +
.meta-row {
260 +
  display: flex;
261 +
  gap: 0.75rem;
262 +
  font-size: 14px;
263 +
}
264 +
265 +
.meta-label {
266 +
  font-size: 12px;
267 +
  opacity: 0.5;
268 +
}
269 +
270 +
.wine-detail-chart {
271 +
  display: flex;
272 +
  justify-content: center;
273 +
}
274 +
275 +
.wine-detail-notes {
276 +
  display: flex;
277 +
  flex-direction: column;
278 +
  gap: 0.25rem;
279 +
}
280 +
281 +
.wine-detail-notes p {
282 +
  white-space: pre-wrap;
283 +
}
284 +
285 +
/* Admin list */
286 +
287 +
.admin-list {
288 +
  display: flex;
289 +
  flex-direction: column;
290 +
  width: 100%;
291 +
}
292 +
293 +
.admin-item {
294 +
  display: flex;
295 +
  justify-content: space-between;
296 +
  align-items: center;
297 +
  padding: 8px 0;
298 +
  border-bottom: 1px solid #333;
299 +
}
300 +
301 +
.admin-item-info {
302 +
  display: flex;
303 +
  flex-direction: column;
304 +
  gap: 2px;
305 +
}
306 +
307 +
.admin-item-name {
308 +
  font-size: 16px;
309 +
}
310 +
311 +
.admin-item-meta {
312 +
  font-size: 12px;
313 +
  opacity: 0.5;
314 +
}
315 +
316 +
.admin-actions {
317 +
  display: flex;
318 +
  gap: 1rem;
319 +
  font-size: 12px;
320 +
}
321 +
322 +
.inline-form {
323 +
  display: inline;
324 +
}
325 +
326 +
.link-button {
327 +
  background: none;
328 +
  border: none;
329 +
  color: #ffffff;
330 +
  cursor: pointer;
331 +
  font-size: 12px;
332 +
  padding: 0;
333 +
}
334 +
335 +
.link-button:hover {
336 +
  opacity: 0.7;
337 +
}
338 +
339 +
/* Score inputs */
340 +
341 +
.image-upload-row {
342 +
  display: flex;
343 +
  align-items: center;
344 +
  gap: 0.75rem;
345 +
}
346 +
347 +
.score-group {
348 +
  display: flex;
349 +
  flex-direction: column;
350 +
  gap: 0.5rem;
351 +
  margin-top: 0.5rem;
352 +
}
353 +
354 +
.score-row {
355 +
  display: flex;
356 +
  align-items: center;
357 +
  gap: 0.75rem;
358 +
}
359 +
360 +
.score-row label {
361 +
  width: 80px;
362 +
  flex-shrink: 0;
363 +
}
364 +
365 +
.score-row input[type="range"] {
366 +
  flex: 1;
367 +
  -webkit-appearance: none;
368 +
  appearance: none;
369 +
  height: 2px;
370 +
  background: #555;
371 +
  border: none;
372 +
  padding: 0;
373 +
}
374 +
375 +
.score-row input[type="range"]::-webkit-slider-thumb {
376 +
  -webkit-appearance: none;
377 +
  appearance: none;
378 +
  width: 14px;
379 +
  height: 14px;
380 +
  background: #ffffff;
381 +
  border: none;
382 +
  border-radius: 0;
383 +
  cursor: pointer;
384 +
}
385 +
386 +
.score-row input[type="range"]::-moz-range-thumb {
387 +
  width: 14px;
388 +
  height: 14px;
389 +
  background: #ffffff;
390 +
  border: none;
391 +
  border-radius: 0;
392 +
  cursor: pointer;
393 +
}
394 +
395 +
.score-value {
396 +
  width: 16px;
397 +
  text-align: center;
398 +
  font-size: 12px;
399 +
  opacity: 0.7;
400 +
}
apps/cellar/templates/admin.html (added) +29 −0
1 +
{% extends "base.html" %}
2 +
{% block title %}Admin - Cellar{% endblock %}
3 +
{% block nav %}
4 +
  <nav class="links">
5 +
    <a href="/admin/new">new</a>
6 +
    <a href="/admin/logout">logout</a>
7 +
  </nav>
8 +
{% endblock %}
9 +
{% block content %}
10 +
  {% if wines.is_empty() %}
11 +
    <p class="empty">no wines yet</p>
12 +
  {% endif %}
13 +
  <div class="admin-list">
14 +
    {% for wine in wines %}
15 +
    <div class="admin-item">
16 +
      <div class="admin-item-info">
17 +
        <a href="/wines/{{ wine.short_id }}" class="admin-item-name">{{ wine.name }}</a>
18 +
        <span class="admin-item-meta">{{ wine.origin }}{% if !wine.grape.is_empty() %} &middot; {{ wine.grape }}{% endif %}</span>
19 +
      </div>
20 +
      <div class="admin-actions">
21 +
        <a href="/admin/edit/{{ wine.short_id }}">edit</a>
22 +
        <form method="POST" action="/admin/delete/{{ wine.short_id }}" class="inline-form" onsubmit="return confirm('delete this wine?')">
23 +
          <button type="submit" class="link-button">delete</button>
24 +
        </form>
25 +
      </div>
26 +
    </div>
27 +
    {% endfor %}
28 +
  </div>
29 +
{% endblock %}
apps/cellar/templates/base.html (added) +27 −0
1 +
<!DOCTYPE html>
2 +
<html lang="en">
3 +
<head>
4 +
  <meta charset="UTF-8">
5 +
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 +
  <title>{% block title %}Cellar{% endblock %}</title>
7 +
  <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
8 +
  <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
9 +
  <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
10 +
  <link rel="manifest" href="/static/site.webmanifest">
11 +
  <link rel="icon" href="/static/favicon.ico">
12 +
  <meta property="og:title" content="Cellar">
13 +
  <meta property="og:image" content="/static/og.png">
14 +
  <meta property="og:type" content="website">
15 +
  <meta name="theme-color" content="#121113" />
16 +
  <link rel="stylesheet" href="/static/styles.css">
17 +
</head>
18 +
<body>
19 +
  <header class="header">
20 +
    <a href="/" class="logo">cellar</a>
21 +
    {% block nav %}{% endblock %}
22 +
  </header>
23 +
  <main>
24 +
    {% block content %}{% endblock %}
25 +
  </main>
26 +
</body>
27 +
</html>
apps/cellar/templates/index.html (added) +20 −0
1 +
{% extends "base.html" %}
2 +
{% block title %}Cellar{% endblock %}
3 +
{% block content %}
4 +
  {% if wines.is_empty() %}
5 +
    <p class="empty">no wines yet</p>
6 +
  {% endif %}
7 +
  <div class="wine-list">
8 +
    {% for item in wines %}
9 +
    <a href="/wines/{{ item.wine.short_id }}" class="wine-card">
10 +
      <div class="wine-pentagon">
11 +
        {{ item.pentagon_svg|safe }}
12 +
      </div>
13 +
      <div class="wine-info">
14 +
        <span class="wine-name">{{ item.wine.name }}</span>
15 +
        <span class="wine-meta">{{ item.wine.origin }}{% if !item.wine.grape.is_empty() %} &middot; {{ item.wine.grape }}{% endif %}</span>
16 +
      </div>
17 +
    </a>
18 +
    {% endfor %}
19 +
  </div>
20 +
{% endblock %}
apps/cellar/templates/login.html (added) +25 −0
1 +
<!DOCTYPE html>
2 +
<html lang="en">
3 +
<head>
4 +
  <meta charset="UTF-8">
5 +
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 +
  <title>Cellar</title>
7 +
  <meta name="theme-color" content="#121113" />
8 +
  <link rel="stylesheet" href="/static/styles.css">
9 +
</head>
10 +
<body>
11 +
  <header class="header">
12 +
    <span class="logo">CELLAR</span>
13 +
  </header>
14 +
  <main>
15 +
    {% if let Some(error) = error %}
16 +
      <p class="error">{{ error }}</p>
17 +
    {% endif %}
18 +
    <form method="POST" action="/admin/login" class="form">
19 +
      <label for="password">password</label>
20 +
      <input type="password" id="password" name="password" autofocus required>
21 +
      <button type="submit">login</button>
22 +
    </form>
23 +
  </main>
24 +
</body>
25 +
</html>
apps/cellar/templates/wine.html (added) +43 −0
1 +
{% extends "base.html" %}
2 +
{% block title %}{{ wine.name }} - Cellar{% endblock %}
3 +
{% block content %}
4 +
  <div class="wine-detail">
5 +
    <h1 class="wine-detail-name">{{ wine.name }}</h1>
6 +
    <div class="wine-detail-top">
7 +
      {% if wine.has_image %}
8 +
      <div class="wine-image-wrap">
9 +
        <img src="/wines/{{ wine.short_id }}/image" alt="{{ wine.name }}" class="wine-image">
10 +
      </div>
11 +
      {% endif %}
12 +
      <div class="wine-detail-chart">
13 +
        {{ pentagon_svg|safe }}
14 +
      </div>
15 +
    </div>
16 +
    <div class="wine-detail-meta">
17 +
      {% if !wine.origin.is_empty() %}
18 +
      <div class="meta-row">
19 +
        <span class="meta-label">origin</span>
20 +
        <span>{{ wine.origin }}</span>
21 +
      </div>
22 +
      {% endif %}
23 +
      {% if !wine.grape.is_empty() %}
24 +
      <div class="meta-row">
25 +
        <span class="meta-label">grape</span>
26 +
        <span>{{ wine.grape }}</span>
27 +
      </div>
28 +
      {% endif %}
29 +
    </div>
30 +
    {% if !wine.notes.is_empty() %}
31 +
    <div class="wine-detail-notes">
32 +
      <span class="meta-label">notes</span>
33 +
      <p>{{ wine.notes }}</p>
34 +
    </div>
35 +
    {% endif %}
36 +
    {% if !wine.background.is_empty() %}
37 +
    <div class="wine-detail-notes">
38 +
      <span class="meta-label">background</span>
39 +
      <p>{{ wine.background }}</p>
40 +
    </div>
41 +
    {% endif %}
42 +
  </div>
43 +
{% endblock %}
apps/cellar/templates/wine_form.html (added) +114 −0
1 +
{% extends "base.html" %}
2 +
{% block title %}{% if wine.is_some() %}Edit{% else %}New{% endif %} Wine - Cellar{% endblock %}
3 +
{% block nav %}
4 +
  <nav class="links">
5 +
    <a href="/admin">back</a>
6 +
    <a href="/admin/logout">logout</a>
7 +
  </nav>
8 +
{% endblock %}
9 +
{% block content %}
10 +
  {% if let Some(error) = error %}
11 +
    <p class="error">{{ error }}</p>
12 +
  {% endif %}
13 +
  <form method="POST" enctype="multipart/form-data"
14 +
    action="{% if let Some(w) = wine %}/admin/edit/{{ w.short_id }}{% else %}/admin/new{% endif %}"
15 +
    class="form">
16 +
17 +
    <label for="image">image</label>
18 +
    <div class="image-upload-row">
19 +
      <input type="file" id="image" name="image" accept="image/*">
20 +
      {% if has_anthropic_key %}
21 +
      <button type="button" id="analyze-btn" onclick="analyzeImage()">analyze</button>
22 +
      {% endif %}
23 +
    </div>
24 +
25 +
    <label for="name">name</label>
26 +
    <input type="text" id="name" name="name" required
27 +
      value="{% if let Some(w) = wine %}{{ w.name }}{% endif %}">
28 +
29 +
    <label for="origin">origin</label>
30 +
    <input type="text" id="origin" name="origin"
31 +
      value="{% if let Some(w) = wine %}{{ w.origin }}{% endif %}">
32 +
33 +
    <label for="grape">grape</label>
34 +
    <input type="text" id="grape" name="grape"
35 +
      value="{% if let Some(w) = wine %}{{ w.grape }}{% endif %}">
36 +
37 +
    <label for="notes">notes</label>
38 +
    <textarea id="notes" name="notes" rows="5">{% if let Some(w) = wine %}{{ w.notes }}{% endif %}</textarea>
39 +
40 +
    <label for="background">background</label>
41 +
    <textarea id="background" name="background" rows="5">{% if let Some(w) = wine %}{{ w.background }}{% endif %}</textarea>
42 +
43 +
    <div class="score-group">
44 +
      <div class="score-row">
45 +
        <label for="sweetness">sweetness</label>
46 +
        <input type="range" id="sweetness" name="sweetness" min="1" max="5"
47 +
          value="{% if let Some(w) = wine %}{{ w.sweetness }}{% else %}3{% endif %}">
48 +
        <span class="score-value" data-for="sweetness">{% if let Some(w) = wine %}{{ w.sweetness }}{% else %}3{% endif %}</span>
49 +
      </div>
50 +
      <div class="score-row">
51 +
        <label for="acidity">acidity</label>
52 +
        <input type="range" id="acidity" name="acidity" min="1" max="5"
53 +
          value="{% if let Some(w) = wine %}{{ w.acidity }}{% else %}3{% endif %}">
54 +
        <span class="score-value" data-for="acidity">{% if let Some(w) = wine %}{{ w.acidity }}{% else %}3{% endif %}</span>
55 +
      </div>
56 +
      <div class="score-row">
57 +
        <label for="tannin">tannin</label>
58 +
        <input type="range" id="tannin" name="tannin" min="1" max="5"
59 +
          value="{% if let Some(w) = wine %}{{ w.tannin }}{% else %}3{% endif %}">
60 +
        <span class="score-value" data-for="tannin">{% if let Some(w) = wine %}{{ w.tannin }}{% else %}3{% endif %}</span>
61 +
      </div>
62 +
      <div class="score-row">
63 +
        <label for="alcohol">alcohol</label>
64 +
        <input type="range" id="alcohol" name="alcohol" min="1" max="5"
65 +
          value="{% if let Some(w) = wine %}{{ w.alcohol }}{% else %}3{% endif %}">
66 +
        <span class="score-value" data-for="alcohol">{% if let Some(w) = wine %}{{ w.alcohol }}{% else %}3{% endif %}</span>
67 +
      </div>
68 +
      <div class="score-row">
69 +
        <label for="body">body</label>
70 +
        <input type="range" id="body" name="body" min="1" max="5"
71 +
          value="{% if let Some(w) = wine %}{{ w.body }}{% else %}3{% endif %}">
72 +
        <span class="score-value" data-for="body">{% if let Some(w) = wine %}{{ w.body }}{% else %}3{% endif %}</span>
73 +
      </div>
74 +
    </div>
75 +
76 +
    <button type="submit">{% if wine.is_some() %}update{% else %}create{% endif %}</button>
77 +
  </form>
78 +
79 +
  <script>
80 +
    document.querySelectorAll('input[type="range"]').forEach(function(input) {
81 +
      input.addEventListener('input', function() {
82 +
        var span = document.querySelector('.score-value[data-for="' + this.id + '"]');
83 +
        if (span) span.textContent = this.value;
84 +
      });
85 +
    });
86 +
87 +
    {% if has_anthropic_key %}
88 +
    async function analyzeImage() {
89 +
      var fileInput = document.getElementById('image');
90 +
      if (!fileInput.files.length) return;
91 +
      var formData = new FormData();
92 +
      formData.append('image', fileInput.files[0]);
93 +
      var btn = document.getElementById('analyze-btn');
94 +
      btn.textContent = 'analyzing...';
95 +
      btn.disabled = true;
96 +
      try {
97 +
        var res = await fetch('/admin/analyze-image', { method: 'POST', body: formData });
98 +
        if (res.ok) {
99 +
          var data = await res.json();
100 +
          if (data.name) document.getElementById('name').value = data.name;
101 +
          if (data.origin) document.getElementById('origin').value = data.origin;
102 +
          if (data.grape) document.getElementById('grape').value = data.grape;
103 +
          if (data.background) document.getElementById('background').value = data.background;
104 +
        }
105 +
      } catch (e) {
106 +
        console.error('Analysis failed:', e);
107 +
      } finally {
108 +
        btn.textContent = 'analyze';
109 +
        btn.disabled = false;
110 +
      }
111 +
    }
112 +
    {% endif %}
113 +
  </script>
114 +
{% endblock %}
dist-workspace.toml +1 −0
6 6
    "cargo:apps/jotts",
7 7
    "cargo:apps/og",
8 8
    "cargo:apps/shrink",
9 +
    "cargo:apps/cellar",
9 10
]
10 11
11 12
# Config for 'dist'