feat: add wishlist to cellar 4c435a15
Steve · 2026-04-10 22:07 8 file(s) · +412 −8
apps/cellar/src/auth.rs +7 −0
35 35
    }
36 36
}
37 37
38 +
pub fn is_authenticated(state: &AppState, headers: &axum::http::HeaderMap) -> bool {
39 +
    if let Some(token) = andromeda_auth::extract_session_cookie(headers) {
40 +
        return is_valid_session(state, &token);
41 +
    }
42 +
    false
43 +
}
44 +
38 45
fn is_valid_session(state: &AppState, token: &str) -> bool {
39 46
    match db::get_session_expiry(&state.db, token) {
40 47
        Ok(Some(expires_at)) => {
apps/cellar/src/db.rs +65 −6
50 50
    pub nose_complexity: i32,
51 51
    pub background: String,
52 52
    pub created_at: String,
53 +
    pub wishlist: bool,
53 54
}
54 55
55 56
pub fn init_db() -> Db {
91 92
    let _ = conn.execute("ALTER TABLE wines ADD COLUMN aroma_intensity INTEGER NOT NULL DEFAULT 3", []);
92 93
    let _ = conn.execute("ALTER TABLE wines ADD COLUMN nose_complexity INTEGER NOT NULL DEFAULT 3", []);
93 94
95 +
    // Migration: add wishlist flag
96 +
    let _ = conn.execute("ALTER TABLE wines ADD COLUMN wishlist INTEGER NOT NULL DEFAULT 0", []);
97 +
94 98
    Arc::new(Mutex::new(conn))
95 99
}
96 100
115 119
        nose_complexity: row.get(16)?,
116 120
        background: row.get(17)?,
117 121
        created_at: row.get(18)?,
122 +
        wishlist: row.get::<_, i32>(19)? != 0,
118 123
    })
119 124
}
120 125
121 126
const WINE_COLUMNS: &str =
122 -
    "id, short_id, name, origin, grape, notes, (image IS NOT NULL) AS has_image, image_mime, sweetness, acidity, tannin, alcohol, body, clarity, color_intensity, aroma_intensity, nose_complexity, background, created_at";
127 +
    "id, short_id, name, origin, grape, notes, (image IS NOT NULL) AS has_image, image_mime, sweetness, acidity, tannin, alcohol, body, clarity, color_intensity, aroma_intensity, nose_complexity, background, created_at, wishlist";
123 128
124 129
pub fn create_wine(
125 130
    db: &Db,
139 144
    aroma_intensity: i32,
140 145
    nose_complexity: i32,
141 146
    background: &str,
147 +
    wishlist: bool,
142 148
) -> Result<Wine, DbError> {
143 149
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
144 150
    let short_id = nanoid!(10);
151 +
    let wishlist_int: i32 = if wishlist { 1 } else { 0 };
145 152
    conn.execute(
146 -
        "INSERT INTO wines (short_id, name, origin, grape, notes, image, image_mime, sweetness, acidity, tannin, alcohol, body, clarity, color_intensity, aroma_intensity, nose_complexity, background)
147 -
         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
148 -
        params![short_id, name, origin, grape, notes, image, image_mime, sweetness, acidity, tannin, alcohol, body, clarity, color_intensity, aroma_intensity, nose_complexity, background],
153 +
        "INSERT INTO wines (short_id, name, origin, grape, notes, image, image_mime, sweetness, acidity, tannin, alcohol, body, clarity, color_intensity, aroma_intensity, nose_complexity, background, wishlist)
154 +
         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
155 +
        params![short_id, name, origin, grape, notes, image, image_mime, sweetness, acidity, tannin, alcohol, body, clarity, color_intensity, aroma_intensity, nose_complexity, background, wishlist_int],
149 156
    )?;
150 157
    let id = conn.last_insert_rowid();
151 158
    let wine = conn.query_row(
156 163
    Ok(wine)
157 164
}
158 165
159 -
pub fn get_all_wines(db: &Db) -> Result<Vec<Wine>, DbError> {
166 +
pub fn get_cellar_wines(db: &Db) -> Result<Vec<Wine>, DbError> {
160 167
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
161 168
    let mut stmt = conn.prepare(&format!(
162 -
        "SELECT {} FROM wines ORDER BY id DESC",
169 +
        "SELECT {} FROM wines WHERE wishlist = 0 ORDER BY id DESC",
163 170
        WINE_COLUMNS
164 171
    ))?;
165 172
    let wines = stmt
166 173
        .query_map([], wine_from_row)?
167 174
        .collect::<Result<Vec<_>, _>>()?;
168 175
    Ok(wines)
176 +
}
177 +
178 +
pub fn get_wishlist_wines(db: &Db) -> Result<Vec<Wine>, DbError> {
179 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
180 +
    let mut stmt = conn.prepare(&format!(
181 +
        "SELECT {} FROM wines WHERE wishlist = 1 ORDER BY id DESC",
182 +
        WINE_COLUMNS
183 +
    ))?;
184 +
    let wines = stmt
185 +
        .query_map([], wine_from_row)?
186 +
        .collect::<Result<Vec<_>, _>>()?;
187 +
    Ok(wines)
188 +
}
189 +
190 +
pub fn promote_wine(db: &Db, short_id: &str) -> Result<bool, DbError> {
191 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
192 +
    let rows = conn.execute(
193 +
        "UPDATE wines SET wishlist = 0 WHERE short_id = ?1 AND wishlist = 1",
194 +
        params![short_id],
195 +
    )?;
196 +
    Ok(rows > 0)
197 +
}
198 +
199 +
pub fn update_wishlist_wine(
200 +
    db: &Db,
201 +
    short_id: &str,
202 +
    name: &str,
203 +
    origin: &str,
204 +
    grape: &str,
205 +
    notes: &str,
206 +
    background: &str,
207 +
) -> Result<Option<Wine>, DbError> {
208 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
209 +
    let rows = conn.execute(
210 +
        "UPDATE wines SET name = ?1, origin = ?2, grape = ?3, notes = ?4, background = ?5 WHERE short_id = ?6 AND wishlist = 1",
211 +
        params![name, origin, grape, notes, background, short_id],
212 +
    )?;
213 +
    if rows == 0 {
214 +
        return Ok(None);
215 +
    }
216 +
    match conn.query_row(
217 +
        &format!(
218 +
            "SELECT {} FROM wines WHERE short_id = ?1",
219 +
            WINE_COLUMNS
220 +
        ),
221 +
        params![short_id],
222 +
        wine_from_row,
223 +
    ) {
224 +
        Ok(wine) => Ok(Some(wine)),
225 +
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
226 +
        Err(e) => Err(DbError::Sqlite(e)),
227 +
    }
169 228
}
170 229
171 230
pub fn get_wine_by_short_id(db: &Db, short_id: &str) -> Result<Option<Wine>, DbError> {
apps/cellar/src/server.rs +234 −2
73 73
    has_anthropic_key: bool,
74 74
}
75 75
76 +
#[derive(Template)]
77 +
#[template(path = "wishlist.html")]
78 +
struct WishlistTemplate {
79 +
    wines: Vec<Wine>,
80 +
    is_admin: bool,
81 +
}
82 +
83 +
#[derive(Template)]
84 +
#[template(path = "wishlist_form.html")]
85 +
struct WishlistFormTemplate {
86 +
    wine: Option<Wine>,
87 +
    error: Option<String>,
88 +
    has_anthropic_key: bool,
89 +
}
90 +
76 91
// --- Query/Form structs ---
77 92
78 93
#[derive(serde::Deserialize, Default)]
382 397
// --- Public handlers ---
383 398
384 399
async fn get_index(State(state): State<Arc<AppState>>) -> Response {
385 -
    match db::get_all_wines(&state.db) {
400 +
    match db::get_cellar_wines(&state.db) {
386 401
        Ok(wines) => {
387 402
            let wines: Vec<WineWithSvg> = wines
388 403
                .into_iter()
470 485
    _session: auth::AuthSession,
471 486
    State(state): State<Arc<AppState>>,
472 487
) -> Response {
473 -
    match db::get_all_wines(&state.db) {
488 +
    match db::get_cellar_wines(&state.db) {
474 489
        Ok(wines) => WebTemplate(AdminTemplate { wines }).into_response(),
475 490
        Err(e) => {
476 491
            tracing::error!("Failed to list wines: {}", e);
657 672
        data.aroma_intensity,
658 673
        data.nose_complexity,
659 674
        &data.background,
675 +
        false,
660 676
    ) {
661 677
        Ok(wine) => Redirect::to(&format!("/wines/{}", wine.short_id)).into_response(),
662 678
        Err(e) => {
734 750
    }
735 751
}
736 752
753 +
// --- Wishlist ---
754 +
755 +
struct WishlistFormData {
756 +
    name: String,
757 +
    origin: String,
758 +
    grape: String,
759 +
    notes: String,
760 +
    background: String,
761 +
    image: Option<Vec<u8>>,
762 +
    image_mime: Option<String>,
763 +
}
764 +
765 +
async fn parse_wishlist_multipart(mut multipart: Multipart) -> Result<WishlistFormData, String> {
766 +
    let mut name = String::new();
767 +
    let mut origin = String::new();
768 +
    let mut grape = String::new();
769 +
    let mut notes = String::new();
770 +
    let mut background = String::new();
771 +
    let mut image: Option<Vec<u8>> = None;
772 +
    let mut image_mime: Option<String> = None;
773 +
774 +
    while let Ok(Some(field)) = multipart.next_field().await {
775 +
        let field_name = field.name().unwrap_or("").to_string();
776 +
        match field_name.as_str() {
777 +
            "image" => {
778 +
                let bytes = field.bytes().await.map_err(|e| format!("Failed to read image: {}", e))?;
779 +
                if !bytes.is_empty() {
780 +
                    let processed = process_image(&bytes)?;
781 +
                    image = Some(processed);
782 +
                    image_mime = Some("image/jpeg".to_string());
783 +
                }
784 +
            }
785 +
            "name" => name = field.text().await.unwrap_or_default(),
786 +
            "origin" => origin = field.text().await.unwrap_or_default(),
787 +
            "grape" => grape = field.text().await.unwrap_or_default(),
788 +
            "notes" => notes = field.text().await.unwrap_or_default(),
789 +
            "background" => background = field.text().await.unwrap_or_default(),
790 +
            _ => {}
791 +
        }
792 +
    }
793 +
794 +
    if name.trim().is_empty() {
795 +
        return Err("Name is required".to_string());
796 +
    }
797 +
798 +
    Ok(WishlistFormData {
799 +
        name: name.trim().to_string(),
800 +
        origin: origin.trim().to_string(),
801 +
        grape: grape.trim().to_string(),
802 +
        notes: notes.trim().to_string(),
803 +
        background: background.trim().to_string(),
804 +
        image,
805 +
        image_mime,
806 +
    })
807 +
}
808 +
809 +
async fn get_wishlist(
810 +
    State(state): State<Arc<AppState>>,
811 +
    headers: axum::http::HeaderMap,
812 +
) -> Response {
813 +
    let is_admin = auth::is_authenticated(&state, &headers);
814 +
    match db::get_wishlist_wines(&state.db) {
815 +
        Ok(wines) => WebTemplate(WishlistTemplate { wines, is_admin }).into_response(),
816 +
        Err(e) => {
817 +
            tracing::error!("Failed to list wishlist: {}", e);
818 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
819 +
        }
820 +
    }
821 +
}
822 +
823 +
async fn get_new_wishlist_wine(
824 +
    _session: auth::AuthSession,
825 +
    State(state): State<Arc<AppState>>,
826 +
    Query(q): Query<FlashQuery>,
827 +
) -> Response {
828 +
    WebTemplate(WishlistFormTemplate {
829 +
        wine: None,
830 +
        error: q.error,
831 +
        has_anthropic_key: state.anthropic_api_key.is_some(),
832 +
    })
833 +
    .into_response()
834 +
}
835 +
836 +
async fn get_edit_wishlist_wine(
837 +
    _session: auth::AuthSession,
838 +
    State(state): State<Arc<AppState>>,
839 +
    Path(short_id): Path<String>,
840 +
    Query(q): Query<FlashQuery>,
841 +
) -> Response {
842 +
    match db::get_wine_by_short_id(&state.db, &short_id) {
843 +
        Ok(Some(wine)) => WebTemplate(WishlistFormTemplate {
844 +
            wine: Some(wine),
845 +
            error: q.error,
846 +
            has_anthropic_key: state.anthropic_api_key.is_some(),
847 +
        })
848 +
        .into_response(),
849 +
        Ok(None) => (StatusCode::NOT_FOUND, Html("Wine not found".to_string())).into_response(),
850 +
        Err(e) => {
851 +
            tracing::error!("Failed to get wine: {}", e);
852 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
853 +
        }
854 +
    }
855 +
}
856 +
857 +
async fn post_new_wishlist_wine(
858 +
    _session: auth::AuthSession,
859 +
    State(state): State<Arc<AppState>>,
860 +
    multipart: Multipart,
861 +
) -> Response {
862 +
    let data = match parse_wishlist_multipart(multipart).await {
863 +
        Ok(data) => data,
864 +
        Err(e) => {
865 +
            return Redirect::to(&format!("/admin/wishlist/new?error={}", urlencoded(&e))).into_response();
866 +
        }
867 +
    };
868 +
869 +
    match db::create_wine(
870 +
        &state.db,
871 +
        &data.name,
872 +
        &data.origin,
873 +
        &data.grape,
874 +
        &data.notes,
875 +
        data.image.as_deref(),
876 +
        data.image_mime.as_deref(),
877 +
        3, 3, 3, 3, 3, 3, 3, 3, 3,
878 +
        &data.background,
879 +
        true,
880 +
    ) {
881 +
        Ok(_) => Redirect::to("/wishlist").into_response(),
882 +
        Err(e) => {
883 +
            tracing::error!("Failed to create wishlist wine: {}", e);
884 +
            Redirect::to("/admin/wishlist/new?error=Failed+to+create+wine").into_response()
885 +
        }
886 +
    }
887 +
}
888 +
889 +
async fn post_edit_wishlist_wine(
890 +
    _session: auth::AuthSession,
891 +
    State(state): State<Arc<AppState>>,
892 +
    Path(short_id): Path<String>,
893 +
    multipart: Multipart,
894 +
) -> Response {
895 +
    let data = match parse_wishlist_multipart(multipart).await {
896 +
        Ok(data) => data,
897 +
        Err(e) => {
898 +
            return Redirect::to(&format!("/admin/wishlist/edit/{}?error={}", short_id, urlencoded(&e)))
899 +
                .into_response();
900 +
        }
901 +
    };
902 +
903 +
    match db::update_wishlist_wine(
904 +
        &state.db,
905 +
        &short_id,
906 +
        &data.name,
907 +
        &data.origin,
908 +
        &data.grape,
909 +
        &data.notes,
910 +
        &data.background,
911 +
    ) {
912 +
        Ok(Some(_)) => {
913 +
            if let Some(image) = &data.image {
914 +
                if let Some(mime) = &data.image_mime {
915 +
                    if let Err(e) = db::update_wine_image(&state.db, &short_id, image, mime) {
916 +
                        tracing::error!("Failed to update wine image: {}", e);
917 +
                    }
918 +
                }
919 +
            }
920 +
            Redirect::to("/wishlist").into_response()
921 +
        }
922 +
        Ok(None) => (StatusCode::NOT_FOUND, Html("Wine not found".to_string())).into_response(),
923 +
        Err(e) => {
924 +
            tracing::error!("Failed to update wishlist wine: {}", e);
925 +
            Redirect::to(&format!(
926 +
                "/admin/wishlist/edit/{}?error=Failed+to+update+wine",
927 +
                short_id
928 +
            ))
929 +
            .into_response()
930 +
        }
931 +
    }
932 +
}
933 +
934 +
async fn post_delete_wishlist_wine(
935 +
    _session: auth::AuthSession,
936 +
    State(state): State<Arc<AppState>>,
937 +
    Path(short_id): Path<String>,
938 +
) -> Response {
939 +
    match db::delete_wine(&state.db, &short_id) {
940 +
        Ok(_) => Redirect::to("/wishlist").into_response(),
941 +
        Err(e) => {
942 +
            tracing::error!("Failed to delete wine: {}", e);
943 +
            Redirect::to("/wishlist").into_response()
944 +
        }
945 +
    }
946 +
}
947 +
948 +
async fn post_promote_wine(
949 +
    _session: auth::AuthSession,
950 +
    State(state): State<Arc<AppState>>,
951 +
    Path(short_id): Path<String>,
952 +
) -> Response {
953 +
    match db::promote_wine(&state.db, &short_id) {
954 +
        Ok(true) => Redirect::to(&format!("/admin/edit/{}", short_id)).into_response(),
955 +
        Ok(false) => (StatusCode::NOT_FOUND, Html("Wine not found".to_string())).into_response(),
956 +
        Err(e) => {
957 +
            tracing::error!("Failed to promote wine: {}", e);
958 +
            Redirect::to("/wishlist").into_response()
959 +
        }
960 +
    }
961 +
}
962 +
737 963
// --- Claude vision handler ---
738 964
739 965
async fn post_analyze_image(
845 1071
        .route("/admin/new", get(get_new_wine).post(post_new_wine))
846 1072
        .route("/admin/edit/{short_id}", get(get_edit_wine).post(post_edit_wine))
847 1073
        .route("/admin/delete/{short_id}", post(post_delete_wine))
1074 +
        // Wishlist public (admin actions inline when authenticated)
1075 +
        .route("/wishlist", get(get_wishlist))
1076 +
        .route("/admin/wishlist/new", get(get_new_wishlist_wine).post(post_new_wishlist_wine))
1077 +
        .route("/admin/wishlist/edit/{short_id}", get(get_edit_wishlist_wine).post(post_edit_wishlist_wine))
1078 +
        .route("/admin/wishlist/delete/{short_id}", post(post_delete_wishlist_wine))
1079 +
        .route("/admin/wishlist/promote/{short_id}", post(post_promote_wine))
848 1080
        // Claude vision
849 1081
        .route("/admin/analyze-image", post(post_analyze_image))
850 1082
        // Static assets
apps/cellar/templates/admin.html +1 −0
3 3
{% block nav %}
4 4
  <nav class="links">
5 5
    <a href="/admin/new">new</a>
6 +
    <a href="/wishlist">wishlist</a>
6 7
  </nav>
7 8
{% endblock %}
8 9
{% block content %}
apps/cellar/templates/index.html +1 −0
3 3
{% block nav %}
4 4
  <nav class="links">
5 5
    <a href="/admin/new">new</a>
6 +
    <a href="/wishlist">wishlist</a>
6 7
  </nav>
7 8
{% endblock %}
8 9
{% block content %}
apps/cellar/templates/wine.html +2 −0
14 14
        <img src="/wines/{{ wine.short_id }}/image" alt="{{ wine.name }}" class="wine-image">
15 15
      </div>
16 16
      {% endif %}
17 +
      {% if !wine.wishlist %}
17 18
      <div class="wine-detail-chart">
18 19
        {{ pentagon_svg|safe }}
19 20
        {{ bars_svg|safe }}
20 21
      </div>
22 +
      {% endif %}
21 23
    </div>
22 24
    <div class="wine-detail-meta">
23 25
      {% if !wine.origin.is_empty() %}
apps/cellar/templates/wishlist.html (added) +34 −0
1 +
{% extends "base.html" %}
2 +
{% block title %}Wishlist - Cellar{% endblock %}
3 +
{% block nav %}
4 +
  <nav class="links">
5 +
    <a href="/admin/wishlist/new">new</a>
6 +
    <a href="/">cellar</a>
7 +
  </nav>
8 +
{% endblock %}
9 +
{% block content %}
10 +
  {% if wines.is_empty() %}
11 +
    <p class="empty">wishlist empty</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 +
      {% if is_admin %}
21 +
      <div class="admin-actions">
22 +
        <a href="/admin/wishlist/edit/{{ wine.short_id }}">edit</a>
23 +
        <form method="POST" action="/admin/wishlist/promote/{{ wine.short_id }}" class="inline-form">
24 +
          <button type="submit" class="link-button">promote</button>
25 +
        </form>
26 +
        <form method="POST" action="/admin/wishlist/delete/{{ wine.short_id }}" class="inline-form" onsubmit="return confirm('delete this wine?')">
27 +
          <button type="submit" class="link-button">delete</button>
28 +
        </form>
29 +
      </div>
30 +
      {% endif %}
31 +
    </div>
32 +
    {% endfor %}
33 +
  </div>
34 +
{% endblock %}
apps/cellar/templates/wishlist_form.html (added) +68 −0
1 +
{% extends "base.html" %}
2 +
{% block title %}{% if wine.is_some() %}Edit{% else %}New{% endif %} Wishlist Wine - Cellar{% endblock %}
3 +
{% block content %}
4 +
  {% if let Some(error) = error %}
5 +
    <p class="error">{{ error }}</p>
6 +
  {% endif %}
7 +
  <form method="POST" enctype="multipart/form-data"
8 +
    action="{% if let Some(w) = wine %}/admin/wishlist/edit/{{ w.short_id }}{% else %}/admin/wishlist/new{% endif %}"
9 +
    class="form">
10 +
11 +
    <label for="image">image</label>
12 +
    <div class="image-upload-row">
13 +
      <input type="file" id="image" name="image" accept="image/*">
14 +
      {% if has_anthropic_key %}
15 +
      <button type="button" id="analyze-btn" onclick="analyzeImage()">analyze</button>
16 +
      {% endif %}
17 +
    </div>
18 +
19 +
    <label for="name">name</label>
20 +
    <input type="text" id="name" name="name" required
21 +
      value="{% if let Some(w) = wine %}{{ w.name }}{% endif %}">
22 +
23 +
    <label for="origin">origin</label>
24 +
    <input type="text" id="origin" name="origin"
25 +
      value="{% if let Some(w) = wine %}{{ w.origin }}{% endif %}">
26 +
27 +
    <label for="grape">grape</label>
28 +
    <input type="text" id="grape" name="grape"
29 +
      value="{% if let Some(w) = wine %}{{ w.grape }}{% endif %}">
30 +
31 +
    <label for="notes">notes</label>
32 +
    <textarea id="notes" name="notes" rows="5">{% if let Some(w) = wine %}{{ w.notes }}{% endif %}</textarea>
33 +
34 +
    <label for="background">background</label>
35 +
    <textarea id="background" name="background" rows="5">{% if let Some(w) = wine %}{{ w.background }}{% endif %}</textarea>
36 +
37 +
    <button type="submit">{% if wine.is_some() %}update{% else %}create{% endif %}</button>
38 +
  </form>
39 +
40 +
  <script>
41 +
    {% if has_anthropic_key %}
42 +
    async function analyzeImage() {
43 +
      var fileInput = document.getElementById('image');
44 +
      if (!fileInput.files.length) return;
45 +
      var formData = new FormData();
46 +
      formData.append('image', fileInput.files[0]);
47 +
      var btn = document.getElementById('analyze-btn');
48 +
      btn.textContent = 'analyzing...';
49 +
      btn.disabled = true;
50 +
      try {
51 +
        var res = await fetch('/admin/analyze-image', { method: 'POST', body: formData });
52 +
        if (res.ok) {
53 +
          var data = await res.json();
54 +
          if (data.name) document.getElementById('name').value = data.name;
55 +
          if (data.origin) document.getElementById('origin').value = data.origin;
56 +
          if (data.grape) document.getElementById('grape').value = data.grape;
57 +
          if (data.background) document.getElementById('background').value = data.background;
58 +
        }
59 +
      } catch (e) {
60 +
        console.error('Analysis failed:', e);
61 +
      } finally {
62 +
        btn.textContent = 'analyze';
63 +
        btn.disabled = false;
64 +
      }
65 +
    }
66 +
    {% endif %}
67 +
  </script>
68 +
{% endblock %}