feat: add wishlist to cellar
4c435a15
8 file(s) · +412 −8
| 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)) => { |
| 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> { |
|
| 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 |
|
| 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 %} |
| 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 %} |
| 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() %} |
| 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() %} · {{ 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 %} |
| 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 %} |