The smokesignal.events web application

feature: Add event thumbnail image support

+573 -61
+7
src/http/errors/blob_error.rs
··· 97 97 #[error("error-smokesignal-blob-37 No header file provided")] 98 98 NoHeaderFile, 99 99 100 + /// Error when no thumbnail file is provided in the upload. 101 + /// 102 + /// This error occurs when the expected thumbnail file field is missing 103 + /// from the multipart form data. 104 + #[error("error-smokesignal-blob-47 No thumbnail file provided")] 105 + NoThumbnailFile, 106 + 100 107 /// Error from image processing operations. 101 108 /// 102 109 /// This error wraps `ImageError` and is automatically converted from
+3
src/http/event_form.rs
··· 147 147 pub header_cid: Option<String>, 148 148 pub header_alt: Option<String>, 149 149 pub header_size: Option<usize>, 150 + 151 + pub thumbnail_cid: Option<String>, 152 + pub thumbnail_alt: Option<String>, 150 153 } 151 154 152 155 impl From<BuildEventForm> for BuildLocationForm {
+54 -5
src/http/event_view.rs
··· 62 62 pub description_tags: Vec<String>, // facet tags from description 63 63 64 64 pub header: Option<(String, String)>, // (cid, alt text) 65 + pub header_cid: Option<String>, 66 + 67 + pub thumbnail_cid: Option<String>, 68 + pub thumbnail_alt: Option<String>, 65 69 66 70 pub require_confirmed_email: bool, 67 71 ··· 363 367 links, 364 368 description_links, 365 369 description_tags, 366 - header: header_image, 370 + header: header_image.clone(), 371 + header_cid: header_image.map(|(cid, _)| cid), 372 + thumbnail_cid: find_thumbnail(event).map(|(cid, _)| cid), 373 + thumbnail_alt: find_thumbnail(event).and_then(|(_, alt)| if alt.is_empty() { None } else { Some(alt) }), 367 374 require_confirmed_email: event.require_confirmed_email, 368 375 disable_direct_rsvp: event.disable_direct_rsvp, 369 376 rsvp_redirect_url: event.rsvp_redirect_url.clone(), ··· 455 462 let alt = media.alt.clone(); 456 463 457 464 let (reported_height, reported_width) = (aspect_ratio.height, aspect_ratio.width); 458 - if !(755..=12000).contains(&reported_height) 465 + if !(200..=12000).contains(&reported_height) 459 466 || !(reported_height..=12000).contains(&reported_width) 460 467 { 461 468 continue; 462 469 } 463 - let is_16_9 = 464 - (((reported_width as f64) / (reported_height as f64)) - (16.0 / 9.0)).abs() < 0.02; 465 - if is_16_9 { 470 + let ratio = (reported_width as f64) / (reported_height as f64); 471 + // Accept 3:1 (new format) or 16:9 (legacy format) 472 + let is_3_1 = (ratio - 3.0).abs() / 3.0 < 0.10; 473 + let is_16_9 = (ratio - (16.0 / 9.0)).abs() < 0.02; 474 + if is_3_1 || is_16_9 { 466 475 // Access the CID from the TypedBlob structure 467 476 let blob_ref = &content.inner.ref_.link; 468 477 return Some((blob_ref.clone(), alt)); ··· 470 479 } 471 480 None 472 481 } 482 + 483 + fn find_thumbnail(event: &Event) -> Option<(String, String)> { 484 + let all_media = if let Some(value) = event 485 + .record 486 + .as_object() 487 + .and_then(|value| value.get("media")) 488 + { 489 + serde_json::from_value::<MediaList>(value.clone()) 490 + } else { 491 + Ok(Vec::default()) 492 + }; 493 + if all_media.is_err() { 494 + return None; 495 + } 496 + let all_media = all_media.unwrap(); 497 + 498 + for media in &all_media { 499 + if media.role != "thumbnail" || media.aspect_ratio.is_none() { 500 + continue; 501 + } 502 + let content = &media.content; 503 + let aspect_ratio = media.aspect_ratio.clone().unwrap(); 504 + let alt = media.alt.clone(); 505 + 506 + let (reported_height, reported_width) = (aspect_ratio.height, aspect_ratio.width); 507 + // Validate size: min 512x512, max 1024x1024 508 + if !(512..=1024).contains(&reported_height) || !(512..=1024).contains(&reported_width) { 509 + continue; 510 + } 511 + // Validate 1:1 aspect ratio (within 5% tolerance) 512 + let ratio = (reported_width as f64) / (reported_height as f64); 513 + if (ratio - 1.0).abs() > 0.05 { 514 + continue; 515 + } 516 + // Access the CID from the TypedBlob structure 517 + let blob_ref = &content.inner.ref_.link; 518 + return Some((blob_ref.clone(), alt)); 519 + } 520 + None 521 + }
+131 -10
src/http/handle_blob.rs
··· 29 29 storage::profile::{profile_get_by_aturi, profile_insert}, 30 30 }; 31 31 32 + use image::GenericImageView; 32 33 use serde::{Deserialize, Serialize}; 33 34 use std::collections::HashMap; 34 35 ··· 39 40 40 41 #[derive(Serialize)] 41 42 struct UploadHeaderResponse { 43 + cid: String, 44 + width: u32, 45 + height: u32, 46 + size: usize, 47 + } 48 + 49 + #[derive(Serialize)] 50 + struct UploadThumbnailResponse { 42 51 cid: String, 43 52 width: u32, 44 53 height: u32, ··· 775 784 // Return JSON response with CID, dimensions, and size 776 785 let response = UploadHeaderResponse { 777 786 cid, 778 - width: 1600, 779 - height: 900, 787 + width: 1500, 788 + height: 500, 780 789 size, 781 790 }; 782 791 ··· 846 855 return Err(BlobError::InvalidImageDimensions); 847 856 } 848 857 849 - // Validate 16:9 aspect ratio (allow 2% deviation) 850 - let is_16_9 = 851 - (((actual_width as f64) / (actual_height as f64)) - (16.0 / 9.0)).abs() < 0.02; 852 - if !is_16_9 { 858 + // Validate 3:1 aspect ratio (allow 10% deviation) for new headers 859 + // Also accept 16:9 (2% deviation) for backward compatibility with existing headers 860 + let aspect = (actual_width as f64) / (actual_height as f64); 861 + let is_3_1 = (aspect - 3.0).abs() / 3.0 < 0.10; 862 + let is_16_9 = (aspect - (16.0 / 9.0)).abs() < 0.02; 863 + if !is_3_1 && !is_16_9 { 853 864 return Err(BlobError::InvalidAspectRatio); 854 865 } 855 866 856 - // Resize to standard dimensions (1600x900) to match process_event_header() 857 - let final_image = if actual_width != 1600 || actual_height != 900 { 858 - img.resize_exact(1600, 900, image::imageops::FilterType::Lanczos3) 867 + // Resize to appropriate dimensions based on aspect ratio 868 + let final_image = if is_3_1 { 869 + // New 3:1 format - resize to 1500x500 870 + if actual_width != 1500 || actual_height != 500 { 871 + img.resize_exact(1500, 500, image::imageops::FilterType::Lanczos3) 872 + } else { 873 + img 874 + } 859 875 } else { 860 - img 876 + // Legacy 16:9 format - resize to 1600x900 877 + if actual_width != 1600 || actual_height != 900 { 878 + img.resize_exact(1600, 900, image::imageops::FilterType::Lanczos3) 879 + } else { 880 + img 881 + } 861 882 }; 862 883 863 884 // Convert to PNG ··· 876 897 tracing::info!(cid = %header_cid, "Successfully stored event header image"); 877 898 Ok(()) 878 899 } 900 + 901 + /// Handle event thumbnail image upload 902 + pub(crate) async fn upload_event_thumbnail( 903 + State(web_context): State<WebContext>, 904 + Cached(auth): Cached<Auth>, 905 + mut multipart: Multipart, 906 + ) -> Result<impl IntoResponse, WebError> { 907 + let current_handle = auth.require_flat()?; 908 + 909 + // Extract the file from multipart data 910 + let mut file_data: Option<Vec<u8>> = None; 911 + 912 + while let Some(field) = multipart 913 + .next_field() 914 + .await 915 + .map_err(|e| BlobError::MultipartError(e.to_string()))? 916 + { 917 + if field.name() == Some("thumbnail") { 918 + file_data = Some( 919 + field 920 + .bytes() 921 + .await 922 + .map_err(|e| BlobError::FileReadFailed(e.to_string()))? 923 + .to_vec(), 924 + ); 925 + break; 926 + } 927 + } 928 + 929 + let file_data = file_data.ok_or(BlobError::NoThumbnailFile)?; 930 + 931 + // Validate image with 3MB limit 932 + crate::image::validate_image(&file_data, 3_000_000)?; 933 + let processed_data = crate::image::process_event_thumbnail(&file_data)?; 934 + 935 + // Get the processed image dimensions 936 + let img = image::load_from_memory(&processed_data) 937 + .map_err(|e| BlobError::ImageLoadFailed(e.to_string()))?; 938 + let (width, height) = img.dimensions(); 939 + 940 + // Get the PDS endpoint for the user 941 + let pds_endpoint = current_handle.pds.clone(); 942 + 943 + // Check AIP session validity before attempting AT Protocol operation 944 + if let AipSessionStatus::Stale = require_valid_aip_session(&web_context, &auth).await? { 945 + return Err(WebError::SessionStale); 946 + } 947 + 948 + // Create DPoP authentication based on backend type 949 + let dpop_auth = match (&auth, &web_context.config.oauth_backend) { 950 + (Auth::Pds { session, .. }, OAuthBackendConfig::ATProtocol { .. }) => { 951 + create_dpop_auth_from_oauth_session(session)? 952 + } 953 + (Auth::Aip { access_token, .. }, OAuthBackendConfig::AIP { hostname, .. }) => { 954 + create_dpop_auth_from_aip_session(&web_context.http_client, hostname, access_token) 955 + .await? 956 + } 957 + _ => { 958 + return Err(BlobError::MismatchedAuthBackend.into()); 959 + } 960 + }; 961 + 962 + // Upload blob to PDS 963 + let blob = upload_blob_to_pds( 964 + &web_context.http_client, 965 + &pds_endpoint, 966 + &dpop_auth, 967 + &processed_data, 968 + "image/png", 969 + ) 970 + .await?; 971 + 972 + // Extract CID and size from blob reference 973 + let cid = blob.inner.ref_.link.clone(); 974 + let size = blob.inner.size as usize; 975 + 976 + // Store the thumbnail image locally in content storage immediately 977 + let image_path = format!("{}.png", cid); 978 + if let Err(err) = web_context 979 + .content_storage 980 + .write_content(&image_path, &processed_data) 981 + .await 982 + { 983 + tracing::warn!( 984 + ?err, 985 + cid = %cid, 986 + "Failed to store event thumbnail image locally, will be fetched from PDS later" 987 + ); 988 + } 989 + 990 + // Return JSON response with CID, dimensions, and size 991 + let response = UploadThumbnailResponse { 992 + cid, 993 + width, 994 + height, 995 + size, 996 + }; 997 + 998 + Ok((StatusCode::OK, axum::Json(response)).into_response()) 999 + }
+68 -19
src/http/handle_create_event.rs
··· 67 67 pub header_cid: Option<String>, 68 68 pub header_alt: Option<String>, 69 69 pub header_size: Option<i64>, 70 + pub thumbnail_cid: Option<String>, 71 + pub thumbnail_alt: Option<String>, 70 72 #[serde(default)] 71 73 pub require_confirmed_email: bool, 72 74 #[serde(default)] ··· 334 336 None 335 337 }; 336 338 337 - // Build media array with header if provided 338 - let media = if let Some(ref header_cid) = build_event_form.header_cid { 339 - // Construct a TypedBlob from the CID using JSON 339 + // Build media array with header and thumbnail if provided 340 + let mut media = Vec::new(); 341 + 342 + if let Some(ref header_cid) = build_event_form.header_cid { 340 343 let blob_json = serde_json::json!({ 341 344 "$type": "blob", 342 345 "ref": { ··· 349 352 let typed_blob: TypedBlob = serde_json::from_value(blob_json) 350 353 .map_err(|e| CreateEventError::InvalidHeaderBlob(e.to_string()))?; 351 354 352 - vec![TypedLexicon::new(Media { 355 + media.push(TypedLexicon::new(Media { 353 356 role: "header".to_string(), 354 357 alt: build_event_form.header_alt.clone().unwrap_or_default(), 355 358 content: typed_blob, 356 359 aspect_ratio: Some(AspectRatio { 357 - width: 1600, 358 - height: 900, 360 + width: 1500, 361 + height: 500, 359 362 }), 360 - })] 361 - } else { 362 - Vec::default() 363 - }; 363 + })); 364 + } 365 + 366 + if let Some(ref thumbnail_cid) = build_event_form.thumbnail_cid { 367 + let blob_json = serde_json::json!({ 368 + "$type": "blob", 369 + "ref": { 370 + "$link": thumbnail_cid 371 + }, 372 + "mimeType": "image/png", 373 + "size": 0 374 + }); 375 + 376 + let typed_blob: TypedBlob = serde_json::from_value(blob_json) 377 + .map_err(|e| CreateEventError::InvalidHeaderBlob(e.to_string()))?; 378 + 379 + media.push(TypedLexicon::new(Media { 380 + role: "thumbnail".to_string(), 381 + alt: build_event_form.thumbnail_alt.clone().unwrap_or_default(), 382 + content: typed_blob, 383 + aspect_ratio: Some(AspectRatio { 384 + width: 1024, 385 + height: 1024, 386 + }), 387 + })); 388 + } 364 389 365 390 let the_record = Event { 366 391 name: build_event_form ··· 658 683 None 659 684 }; 660 685 661 - // Build media array with header if provided 662 - let media = if let Some(ref header_cid) = request.header_cid { 686 + // Build media array with header and thumbnail if provided 687 + let mut media = Vec::new(); 688 + 689 + if let Some(ref header_cid) = request.header_cid { 663 690 let blob_json = serde_json::json!({ 664 691 "$type": "blob", 665 692 "ref": { ··· 672 699 let typed_blob: TypedBlob = serde_json::from_value(blob_json) 673 700 .map_err(|e| CreateEventError::InvalidHeaderBlob(e.to_string()))?; 674 701 675 - vec![TypedLexicon::new(Media { 702 + media.push(TypedLexicon::new(Media { 676 703 role: "header".to_string(), 677 704 alt: request.header_alt.clone().unwrap_or_default(), 678 705 content: typed_blob, 679 706 aspect_ratio: Some(AspectRatio { 680 - width: 1600, 681 - height: 900, 707 + width: 1500, 708 + height: 500, 682 709 }), 683 - })] 684 - } else { 685 - Vec::default() 686 - }; 710 + })); 711 + } 712 + 713 + if let Some(ref thumbnail_cid) = request.thumbnail_cid { 714 + let blob_json = serde_json::json!({ 715 + "$type": "blob", 716 + "ref": { 717 + "$link": thumbnail_cid 718 + }, 719 + "mimeType": "image/png", 720 + "size": 0 721 + }); 722 + 723 + let typed_blob: TypedBlob = serde_json::from_value(blob_json) 724 + .map_err(|e| CreateEventError::InvalidHeaderBlob(e.to_string()))?; 725 + 726 + media.push(TypedLexicon::new(Media { 727 + role: "thumbnail".to_string(), 728 + alt: request.thumbnail_alt.clone().unwrap_or_default(), 729 + content: typed_blob, 730 + aspect_ratio: Some(AspectRatio { 731 + width: 1024, 732 + height: 1024, 733 + }), 734 + })); 735 + } 687 736 688 737 // Check AIP session validity before attempting AT Protocol operation 689 738 if let AipSessionStatus::Stale = require_valid_aip_session(&web_context, &auth).await? {
+38 -9
src/http/handle_edit_event.rs
··· 221 221 None 222 222 }; 223 223 224 - // Build media 225 - let media = if let Some(ref header_cid) = request.header_cid { 224 + // Build media with header and thumbnail if provided 225 + let mut media = Vec::new(); 226 + 227 + if let Some(ref header_cid) = request.header_cid { 226 228 let blob_json = serde_json::json!({ 227 229 "$type": "blob", 228 230 "ref": {"$link": header_cid}, ··· 240 242 } 241 243 }; 242 244 243 - vec![TypedLexicon::new(Media { 245 + media.push(TypedLexicon::new(Media { 244 246 role: "header".to_string(), 245 247 alt: request.header_alt.clone().unwrap_or_default(), 246 248 content: typed_blob, 247 249 aspect_ratio: Some(AspectRatio { 248 - width: 1600, 249 - height: 900, 250 + width: 1500, 251 + height: 500, 252 + }), 253 + })); 254 + } 255 + 256 + if let Some(ref thumbnail_cid) = request.thumbnail_cid { 257 + let blob_json = serde_json::json!({ 258 + "$type": "blob", 259 + "ref": {"$link": thumbnail_cid}, 260 + "mimeType": "image/png", 261 + "size": 0 262 + }); 263 + 264 + let typed_blob: atproto_record::lexicon::TypedBlob = match serde_json::from_value(blob_json) { 265 + Ok(blob) => blob, 266 + Err(e) => { 267 + return Ok(( 268 + StatusCode::BAD_REQUEST, 269 + Json(json!({"error": format!("Invalid thumbnail blob: {}", e)})) 270 + ).into_response()); 271 + } 272 + }; 273 + 274 + media.push(TypedLexicon::new(Media { 275 + role: "thumbnail".to_string(), 276 + alt: request.thumbnail_alt.clone().unwrap_or_default(), 277 + content: typed_blob, 278 + aspect_ratio: Some(AspectRatio { 279 + width: 1024, 280 + height: 1024, 250 281 }), 251 - })] 252 - } else { 253 - Vec::default() 254 - }; 282 + })); 283 + } 255 284 256 285 // Check AIP session validity before attempting AT Protocol operation 257 286 if let AipSessionStatus::Stale = require_valid_aip_session(&ctx.web_context, &ctx.auth).await? {
+2
src/http/handle_manage_event.rs
··· 232 232 header_cid: None, 233 233 header_alt: None, 234 234 header_size: None, 235 + thumbnail_cid: None, 236 + thumbnail_alt: None, 235 237 }; 236 238 237 239 // Initialize timezone and form helpers (needed for details tab)
+2
src/http/handle_manage_event_content.rs
··· 316 316 header_cid: None, 317 317 header_alt: None, 318 318 header_size: None, 319 + thumbnail_cid: None, 320 + thumbnail_alt: None, 319 321 }; 320 322 build_event_form.private_content = form.private_content.clone(); 321 323 build_event_form.private_content_criteria_going_confirmed = Some(
+6 -2
src/http/server.rs
··· 50 50 handle_admin_profile_index_rebuild, 51 51 }, 52 52 handle_blob::{ 53 - delete_profile_avatar, delete_profile_banner, upload_event_header, upload_profile_avatar, 54 - upload_profile_banner, 53 + delete_profile_avatar, delete_profile_banner, upload_event_header, upload_event_thumbnail, 54 + upload_profile_avatar, upload_profile_banner, 55 55 }, 56 56 handle_bulk_accept_rsvps::handle_bulk_accept_rsvps, 57 57 handle_content::handle_content, ··· 303 303 .route( 304 304 "/event/upload-header", 305 305 post(upload_event_header).layer(DefaultBodyLimit::max(5 * 1024 * 1024)), // 5MB limit 306 + ) 307 + .route( 308 + "/event/upload-thumbnail", 309 + post(upload_event_thumbnail).layer(DefaultBodyLimit::max(5 * 1024 * 1024)), // 5MB limit 306 310 ) 307 311 .route("/ics/{*aturi}", get(handle_export_ics)) 308 312 .route("/{handle_slug}/ics", get(handle_event_ics))
+42 -6
src/image.rs
··· 80 80 Ok(png_buffer.into_inner()) 81 81 } 82 82 83 - /// Process event header image: validate 16:9 aspect ratio, resize to 1600x900, convert to PNG 83 + /// Process event header image: validate 3:1 aspect ratio, resize to 1500x500, convert to PNG 84 84 pub(crate) fn process_event_header(data: &[u8]) -> Result<Vec<u8>, ImageError> { 85 85 // Load the image 86 86 let img = image::load_from_memory(data) ··· 88 88 89 89 let (width, height) = img.dimensions(); 90 90 91 - // Validate 16:9 aspect ratio (allow 10% deviation) 91 + // Validate 3:1 aspect ratio (allow 10% deviation) 92 92 let aspect_ratio = width as f32 / height as f32; 93 - let expected_ratio = 16.0 / 9.0; 93 + let expected_ratio = 3.0 / 1.0; 94 94 if (aspect_ratio - expected_ratio).abs() / expected_ratio > 0.10 { 95 95 return Err(ImageError::InvalidEventHeaderAspectRatio { width, height }); 96 96 } 97 97 98 - // Resize to 1600x900 if needed 99 - let resized = if width != 1600 || height != 900 { 100 - img.resize_exact(1600, 900, FilterType::Lanczos3) 98 + // Resize to 1500x500 if needed 99 + let resized = if width != 1500 || height != 500 { 100 + img.resize_exact(1500, 500, FilterType::Lanczos3) 101 101 } else { 102 102 img 103 103 }; ··· 110 110 111 111 Ok(png_buffer.into_inner()) 112 112 } 113 + 114 + /// Process event thumbnail image: validate 1:1 aspect ratio, resize within 512x512 to 1024x1024, convert to PNG 115 + pub(crate) fn process_event_thumbnail(data: &[u8]) -> Result<Vec<u8>, ImageError> { 116 + // Load the image 117 + let img = image::load_from_memory(data) 118 + .map_err(|e| ImageError::ThumbnailLoadFailed(e.to_string()))?; 119 + 120 + let (width, height) = img.dimensions(); 121 + 122 + // Validate 1:1 aspect ratio (allow 5% deviation) 123 + let aspect_ratio = width as f32 / height as f32; 124 + if (aspect_ratio - 1.0).abs() > 0.05 { 125 + return Err(ImageError::InvalidThumbnailAspectRatio { width, height }); 126 + } 127 + 128 + // Validate minimum size of 512x512 129 + if width < 512 || height < 512 { 130 + return Err(ImageError::ThumbnailTooSmall { width, height }); 131 + } 132 + 133 + // Resize to maximum 1024x1024 if larger, otherwise keep original size 134 + let resized = if width > 1024 || height > 1024 { 135 + img.resize_exact(1024, 1024, FilterType::Lanczos3) 136 + } else { 137 + // Keep original size if within bounds (512-1024) 138 + img 139 + }; 140 + 141 + // Convert to PNG 142 + let mut png_buffer = std::io::Cursor::new(Vec::new()); 143 + resized 144 + .write_to(&mut png_buffer, ImageFormat::Png) 145 + .map_err(|e| ImageError::ThumbnailEncodeFailed(e.to_string()))?; 146 + 147 + Ok(png_buffer.into_inner()) 148 + }
+41 -3
src/image_errors.rs
··· 84 84 #[error("error-smokesignal-image-9 Failed to load event header image: {0}")] 85 85 EventHeaderLoadFailed(String), 86 86 87 - /// Error when event header aspect ratio is not 16:9. 87 + /// Error when event header aspect ratio is not 3:1. 88 88 /// 89 89 /// This error occurs when an event header image doesn't have the required 90 - /// 16:9 aspect ratio (allowing 10% deviation). 91 - #[error("error-smokesignal-image-10 Event header must have 16:9 aspect ratio: got {width}:{height}")] 90 + /// 3:1 aspect ratio (allowing 10% deviation). 91 + #[error("error-smokesignal-image-10 Event header must have 3:1 aspect ratio: got {width}:{height}")] 92 92 InvalidEventHeaderAspectRatio { 93 93 /// The width of the image in pixels. 94 94 width: u32, ··· 102 102 /// and processing the event header image. 103 103 #[error("error-smokesignal-image-11 Failed to encode event header as PNG: {0}")] 104 104 EventHeaderEncodeFailed(String), 105 + 106 + /// Error when loading a thumbnail image fails. 107 + /// 108 + /// This error occurs when attempting to load image data for thumbnail 109 + /// processing, typically due to invalid format or corrupted data. 110 + #[error("error-smokesignal-image-12 Failed to load thumbnail image: {0}")] 111 + ThumbnailLoadFailed(String), 112 + 113 + /// Error when thumbnail aspect ratio is not 1:1. 114 + /// 115 + /// This error occurs when a thumbnail image doesn't have the required 116 + /// square aspect ratio (allowing 5% deviation). 117 + #[error("error-smokesignal-image-13 Thumbnail must have 1:1 aspect ratio: got {width}:{height}")] 118 + InvalidThumbnailAspectRatio { 119 + /// The width of the image in pixels. 120 + width: u32, 121 + /// The height of the image in pixels. 122 + height: u32, 123 + }, 124 + 125 + /// Error when thumbnail image is too small. 126 + /// 127 + /// This error occurs when a thumbnail image is smaller than the minimum 128 + /// required size of 512x512 pixels. 129 + #[error("error-smokesignal-image-14 Thumbnail must be at least 512x512: got {width}x{height}")] 130 + ThumbnailTooSmall { 131 + /// The width of the image in pixels. 132 + width: u32, 133 + /// The height of the image in pixels. 134 + height: u32, 135 + }, 136 + 137 + /// Error when encoding a thumbnail as PNG fails. 138 + /// 139 + /// This error occurs when the PNG encoding process fails after resizing 140 + /// and processing the thumbnail image. 141 + #[error("error-smokesignal-image-15 Failed to encode thumbnail as PNG: {0}")] 142 + ThumbnailEncodeFailed(String), 105 143 }
+171 -7
templates/en-us/create_event.alpine.html
··· 138 138 </label> 139 139 </div> 140 140 </div> 141 - <p class="help">Upload a 16:9 header image for your event (max 3MB). Crop to the desired area before uploading.</p> 141 + <p class="help">Upload a 3:1 banner image for your event (max 3MB). Crop to the desired area before uploading.</p> 142 142 143 143 <!-- Cropper Canvas --> 144 144 <canvas id="headerCanvas" x-show="showCropper" style="display: none; max-width: 100%; margin-top: 1rem; border: 1px solid #dbdbdb;"></canvas> ··· 154 154 155 155 <!-- Header Preview --> 156 156 <div x-show="formData.header_cid" style="display: none; margin-top: 10px;"> 157 - <img :src="headerPreviewUrl" style="max-width: 400px; aspect-ratio: 16/9; object-fit: cover; border: 1px solid #dbdbdb;"> 157 + <img :src="headerPreviewUrl" style="max-width: 400px; aspect-ratio: 3/1; object-fit: cover; border: 1px solid #dbdbdb;"> 158 158 <br> 159 159 <button type="button" class="button is-small is-danger mt-2" @click="removeHeader()">Remove Header</button> 160 160 </div> 161 161 </div> 162 162 163 + <!-- Thumbnail Image Upload --> 164 + <div class="field"> 165 + <label class="label">Thumbnail Image (optional)</label> 166 + <div class="control"> 167 + <div class="file"> 168 + <label class="file-label"> 169 + <input 170 + class="file-input" 171 + type="file" 172 + id="thumbnailInput" 173 + accept="image/png,image/jpeg" 174 + @change="handleThumbnailImageSelect($event)"> 175 + <span class="file-cta"> 176 + <span class="icon"><i class="fas fa-upload"></i></span> 177 + <span class="file-label">Choose thumbnail image...</span> 178 + </span> 179 + </label> 180 + </div> 181 + </div> 182 + <p class="help">Upload a 1:1 square thumbnail (512x512 to 1024x1024) for event cards and social sharing.</p> 183 + 184 + <!-- Thumbnail Cropper Canvas --> 185 + <canvas id="thumbnailCanvas" x-show="showThumbnailCropper" style="display: none; max-width: 400px; margin-top: 1rem; border: 1px solid #dbdbdb;"></canvas> 186 + <button 187 + type="button" 188 + x-show="showThumbnailCropper" 189 + @click="uploadCroppedThumbnail()" 190 + class="button is-primary mt-2" 191 + style="display: none;" 192 + :disabled="uploadingThumbnail"> 193 + <span x-text="uploadingThumbnail ? 'Uploading...' : 'Upload Cropped Thumbnail'"></span> 194 + </button> 195 + 196 + <!-- Thumbnail Preview --> 197 + <div x-show="formData.thumbnail_cid" style="display: none; margin-top: 10px;"> 198 + <img :src="thumbnailPreviewUrl" style="width: 150px; height: 150px; object-fit: cover; border: 1px solid #dbdbdb;"> 199 + <br> 200 + <!-- Thumbnail Alt Text --> 201 + <div class="field mt-2"> 202 + <label class="label is-small">Thumbnail Alt Text (optional)</label> 203 + <div class="control"> 204 + <input type="text" class="input is-small" x-model="formData.thumbnail_alt" placeholder="Describe the thumbnail for accessibility" maxlength="200"> 205 + </div> 206 + </div> 207 + <button type="button" class="button is-small is-danger mt-2" @click="removeThumbnail()">Remove Thumbnail</button> 208 + </div> 209 + </div> 210 + 163 211 <!-- Status and Mode --> 164 212 <div class="field"> 165 213 <div class="field-body"> ··· 518 566 header_cid: {% if build_event_form.header_cid %}{{ build_event_form.header_cid | tojson }}{% else %}null{% endif %}, 519 567 header_alt: {% if build_event_form.header_alt %}{{ build_event_form.header_alt | tojson }}{% else %}null{% endif %}, 520 568 header_size: {% if build_event_form.header_size %}{{ build_event_form.header_size }}{% else %}null{% endif %}, 569 + thumbnail_cid: {% if build_event_form.thumbnail_cid %}{{ build_event_form.thumbnail_cid | tojson }}{% else %}null{% endif %}, 570 + thumbnail_alt: {% if build_event_form.thumbnail_alt %}{{ build_event_form.thumbnail_alt | tojson }}{% else %}null{% endif %}, 521 571 require_confirmed_email: {% if build_event_form.require_confirmed_email %}true{% else %}false{% endif %}, 522 572 send_notifications: false, 523 573 }, ··· 526 576 submitting: false, 527 577 submitted: false, 528 578 uploading: false, 579 + uploadingThumbnail: false, 529 580 errorMessage: null, 530 581 eventUrl: null, 531 582 headerCropper: null, 583 + thumbnailCropper: null, 532 584 showCropper: false, 585 + showThumbnailCropper: false, 533 586 showDescriptionPreview: false, 534 587 descriptionPreviewHtml: '', 535 588 loadingPreview: false, ··· 616 669 return this.formData.header_cid ? `/content/${this.formData.header_cid}.png` : null; 617 670 }, 618 671 672 + get thumbnailPreviewUrl() { 673 + return this.formData.thumbnail_cid ? `/content/${this.formData.thumbnail_cid}.png` : null; 674 + }, 675 + 619 676 addLocation() { 620 677 this.formData.locations.push({ 621 678 country: '', ··· 717 774 this.headerCropper.destroy(); 718 775 } 719 776 720 - // Initialize cropper with 16:9 aspect ratio 777 + // Initialize cropper with 3:1 aspect ratio 721 778 this.headerCropper = new Cropper(canvas, { 722 - aspectRatio: 16 / 9, 779 + aspectRatio: 3 / 1, 723 780 viewMode: 1, 724 781 autoCropArea: 1, 725 782 responsive: true, ··· 739 796 try { 740 797 const blob = await new Promise((resolve) => { 741 798 this.headerCropper.getCroppedCanvas({ 742 - width: 1600, 743 - height: 900, 799 + width: 1500, 800 + height: 500, 744 801 }).toBlob(resolve, 'image/png'); 745 802 }); 746 803 ··· 791 848 this.showCropper = false; 792 849 793 850 // Clear file input 794 - document.querySelector('input[type=file]').value = ''; 851 + const headerInput = document.querySelector('.file-input:not(#thumbnailInput)'); 852 + if (headerInput) headerInput.value = ''; 853 + }, 854 + 855 + handleThumbnailImageSelect(event) { 856 + const file = event.target.files[0]; 857 + if (!file) return; 858 + 859 + // Check file size (3MB max) 860 + if (file.size > 3000000) { 861 + alert('Image must be smaller than 3MB'); 862 + event.target.value = ''; 863 + return; 864 + } 865 + 866 + // Read the file and show cropper 867 + const reader = new FileReader(); 868 + reader.onload = (e) => { 869 + const img = new Image(); 870 + img.onload = () => { 871 + const canvas = document.getElementById('thumbnailCanvas'); 872 + canvas.width = img.width; 873 + canvas.height = img.height; 874 + this.showThumbnailCropper = true; 875 + 876 + const ctx = canvas.getContext('2d'); 877 + ctx.drawImage(img, 0, 0); 878 + 879 + // Destroy existing cropper if any 880 + if (this.thumbnailCropper) { 881 + this.thumbnailCropper.destroy(); 882 + } 883 + 884 + // Initialize cropper with 1:1 aspect ratio 885 + this.thumbnailCropper = new Cropper(canvas, { 886 + aspectRatio: 1, 887 + viewMode: 1, 888 + autoCropArea: 1, 889 + responsive: true, 890 + background: false, 891 + }); 892 + }; 893 + img.src = e.target.result; 894 + }; 895 + reader.readAsDataURL(file); 896 + }, 897 + 898 + async uploadCroppedThumbnail() { 899 + if (!this.thumbnailCropper) return; 900 + 901 + this.uploadingThumbnail = true; 902 + 903 + try { 904 + const blob = await new Promise((resolve) => { 905 + this.thumbnailCropper.getCroppedCanvas({ 906 + width: 1024, 907 + height: 1024, 908 + }).toBlob(resolve, 'image/png'); 909 + }); 910 + 911 + const formData = new FormData(); 912 + formData.append('thumbnail', blob, 'thumbnail.png'); 913 + 914 + const response = await fetch('/event/upload-thumbnail', { 915 + method: 'POST', 916 + body: formData 917 + }); 918 + 919 + if (response.ok) { 920 + const data = await response.json(); 921 + this.formData.thumbnail_cid = data.cid; 922 + this.formData.thumbnail_alt = ''; 923 + 924 + // Hide cropper 925 + this.showThumbnailCropper = false; 926 + if (this.thumbnailCropper) { 927 + this.thumbnailCropper.destroy(); 928 + this.thumbnailCropper = null; 929 + } 930 + 931 + // Clear file input 932 + document.getElementById('thumbnailInput').value = ''; 933 + } else { 934 + alert('Failed to upload thumbnail image. Please try again.'); 935 + } 936 + } catch (error) { 937 + console.error('Upload error:', error); 938 + alert('Failed to upload thumbnail image'); 939 + } finally { 940 + this.uploadingThumbnail = false; 941 + } 942 + }, 943 + 944 + removeThumbnail() { 945 + this.formData.thumbnail_cid = null; 946 + this.formData.thumbnail_alt = null; 947 + 948 + // Hide and destroy cropper if active 949 + if (this.thumbnailCropper) { 950 + this.thumbnailCropper.destroy(); 951 + this.thumbnailCropper = null; 952 + } 953 + this.showThumbnailCropper = false; 954 + 955 + // Clear file input 956 + document.getElementById('thumbnailInput').value = ''; 795 957 }, 796 958 797 959 clearStartDateTime() { ··· 930 1092 header_cid: null, 931 1093 header_alt: null, 932 1094 header_size: null, 1095 + thumbnail_cid: null, 1096 + thumbnail_alt: null, 933 1097 require_confirmed_email: false, 934 1098 send_notifications: false, 935 1099 };
+8
templates/en-us/view_event.html
··· 7 7 <meta property="og:site_name" content="Smoke Signal" /> 8 8 <meta property="og:type" content="website" /> 9 9 <meta property="og:url" content="{{ base }}{{ event.site_url }}" /> 10 + {% if event.thumbnail_cid %} 11 + <meta property="og:image" content="{{ base }}/content/{{ event.thumbnail_cid }}.png" /> 12 + <meta property="og:image:width" content="1024" /> 13 + <meta property="og:image:height" content="1024" /> 14 + {% if event.thumbnail_alt %}<meta property="og:image:alt" content="{{ event.thumbnail_alt }}" />{% endif %} 15 + {% elif event.header_cid %} 16 + <meta property="og:image" content="{{ base }}/content/{{ event.header_cid }}.png" /> 17 + {% endif %} 10 18 <script type="application/ld+json"> 11 19 { 12 20 "@context": "https://schema.org",