grain.social is a photo sharing platform built on atproto.

feat: Adds new gallery social images for og:image to display when sharing to social media or any platform that respects open graph

refactor: Darkroom service to only render one template/screenshot for now, update algorithm to pack all of the gallery images within the constraints of the open graph preview size of 1200x630

+618 -602
+1
fly.toml
··· 17 17 GOATCOUNTER_URL = 'https://grain.goatcounter.com/count' 18 18 USE_CDN = 'true' 19 19 PDS_HOST_URL = 'https://ansel.grainsocial.network' 20 + DARKROOM_HOST_URL = 'https://grain.social' 20 21 21 22 [[mounts]] 22 23 source = "litefs"
+30 -1
services/darkroom/Cargo.lock
··· 236 236 dependencies = [ 237 237 "anyhow", 238 238 "axum", 239 - "base64", 240 239 "chrono", 241 240 "fantoccini", 242 241 "futures", ··· 545 544 "http-body", 546 545 "pin-project-lite", 547 546 ] 547 + 548 + [[package]] 549 + name = "http-range-header" 550 + version = "0.4.2" 551 + source = "registry+https://github.com/rust-lang/crates.io-index" 552 + checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" 548 553 549 554 [[package]] 550 555 name = "httparse" ··· 966 971 checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 967 972 968 973 [[package]] 974 + name = "mime_guess" 975 + version = "2.0.5" 976 + source = "registry+https://github.com/rust-lang/crates.io-index" 977 + checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" 978 + dependencies = [ 979 + "mime", 980 + "unicase", 981 + ] 982 + 983 + [[package]] 969 984 name = "minijinja" 970 985 version = "2.11.0" 971 986 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1707 1722 dependencies = [ 1708 1723 "bitflags", 1709 1724 "bytes", 1725 + "futures-util", 1710 1726 "http 1.3.1", 1711 1727 "http-body", 1712 1728 "http-body-util", 1729 + "http-range-header", 1730 + "httpdate", 1731 + "mime", 1732 + "mime_guess", 1733 + "percent-encoding", 1713 1734 "pin-project-lite", 1735 + "tokio", 1736 + "tokio-util", 1714 1737 "tower-layer", 1715 1738 "tower-service", 1716 1739 "tracing", ··· 1809 1832 version = "0.2.5" 1810 1833 source = "registry+https://github.com/rust-lang/crates.io-index" 1811 1834 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 1835 + 1836 + [[package]] 1837 + name = "unicase" 1838 + version = "2.8.1" 1839 + source = "registry+https://github.com/rust-lang/crates.io-index" 1840 + checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" 1812 1841 1813 1842 [[package]] 1814 1843 name = "unicode-ident"
+1 -2
services/darkroom/Cargo.toml
··· 16 16 urlencoding = "2.1" 17 17 fantoccini = "0.22" 18 18 tower = "0.4" 19 - tower-http = { version = "0.5", features = ["cors", "trace"] } 19 + tower-http = { version = "0.5", features = ["cors", "trace", "fs"] } 20 20 thiserror = "1.0" 21 - base64 = "0.22" 22 21 futures = "0.3" 23 22 chrono = "0.4.41" 24 23
+6
services/darkroom/flake.nix
··· 73 73 "${pkgs.corefonts}/share/fonts" 74 74 "${pkgs.dejavu_fonts}/share/fonts" 75 75 "${pkgs.liberation_ttf}/share/fonts" 76 + "${pkgs.noto-fonts}/share/fonts" 77 + "${pkgs.noto-fonts-color-emoji}/share/fonts" 76 78 ]; 77 79 fontsConf = pkgs.makeFontsConf { fontDirectories = fontDirs; }; 78 80 in pkgs.dockerTools.buildImage { ··· 86 88 pkgs.corefonts 87 89 pkgs.dejavu_fonts 88 90 pkgs.liberation_ttf 91 + pkgs.noto-fonts 92 + pkgs.noto-fonts-color-emoji 89 93 ]; 90 94 91 95 runAsRoot = '' ··· 103 107 "CHROME_PATH=${pkgs.chromium}/bin/chromium" 104 108 "CHROMEDRIVER_PATH=${pkgs.chromedriver}/bin/chromedriver" 105 109 "BASE_URL=http://grain-darkroom.internal:8080" 110 + "GRAIN_BASE_URL=https://grain.social" 111 + "PORT=8080" 106 112 "FONTCONFIG_FILE=${fontsConf}" 107 113 ]; 108 114 ExposedPorts = {
+27 -24
services/darkroom/src/composite_handler.rs
··· 1 - use crate::gallery_service::{extract_thumbnails, fetch_gallery_data}; 2 - use crate::screenshot_service::{build_preview_url, capture_screenshot}; 3 - use anyhow::{anyhow, Result}; 1 + use crate::gallery_service::fetch_gallery_data; 2 + use crate::screenshot_service::capture_screenshot; 3 + use anyhow::{Result, anyhow}; 4 4 use axum::body::Body; 5 5 use axum::http::{HeaderMap, HeaderValue}; 6 6 use axum::response::Response; 7 7 use std::collections::HashMap; 8 8 use tracing::info; 9 9 10 - pub async fn handle_composite_api(params: HashMap<String, String>) -> Result<Response<Body>> { 10 + pub async fn handle_adaptive_composite_api( 11 + params: HashMap<String, String>, 12 + ) -> Result<Response<Body>> { 11 13 let gallery_uri = params 12 14 .get("uri") 13 15 .ok_or_else(|| anyhow!("Missing uri parameter"))?; 14 16 15 - // Fetch gallery data 17 + // Fetch gallery data (this already includes aspect ratios) 16 18 let gallery_data = fetch_gallery_data(gallery_uri).await?; 17 19 18 - // Extract thumbnail URLs 19 - let thumb_urls = extract_thumbnails(&gallery_data, 9)?; 20 - 21 - info!("Extracted thumbnails: {:?}", thumb_urls); 22 20 info!( 23 - "Building preview URL with title: {:?}", 24 - gallery_data.title.as_deref().unwrap_or("") 21 + "Adaptive composite API with {} items from URI", 22 + gallery_data.items.len() 25 23 ); 26 24 27 - // Build preview URL for screenshot 28 - let base_url = std::env::var("BASE_URL") 29 - .unwrap_or_else(|_| "http://[::]:8080".to_string()); 25 + // Build preview URL with just the gallery URI (client-side fetching) 26 + let base_url = std::env::var("BASE_URL").unwrap_or_else(|_| "http://[::]:8080".to_string()); 30 27 31 - let preview_url = build_preview_url( 32 - &base_url, 33 - &thumb_urls, 34 - gallery_data.title.as_deref().unwrap_or(""), 35 - gallery_data 36 - .creator 37 - .as_ref() 38 - .and_then(|c| c.handle.as_deref()) 39 - .unwrap_or(""), 28 + let preview_url = format!( 29 + "{}/gallery-preview?uri={}&title={}&handle={}", 30 + base_url, 31 + urlencoding::encode(gallery_uri), 32 + urlencoding::encode(gallery_data.title.as_deref().unwrap_or("")), 33 + urlencoding::encode( 34 + &gallery_data 35 + .creator 36 + .as_ref() 37 + .and_then(|c| c.handle.as_deref()) 38 + .unwrap_or("") 39 + ) 40 40 ); 41 41 42 - info!("Capturing screenshot for preview URL: {}", preview_url); 42 + info!( 43 + "Capturing screenshot for adaptive preview URL: {}", 44 + preview_url 45 + ); 43 46 44 47 // Capture screenshot 45 48 let screenshot = capture_screenshot(&preview_url).await?;
+3 -21
services/darkroom/src/gallery_service.rs
··· 4 4 use tracing::info; 5 5 6 6 pub async fn fetch_gallery_data(gallery_uri: &str) -> Result<GalleryResponse> { 7 + let base_url = std::env::var("GRAIN_BASE_URL").unwrap_or_else(|_| "https://grain.social".to_string()); 7 8 let gallery_url = format!( 8 - "https://grain.social/xrpc/social.grain.gallery.getGallery?uri={}", 9 + "{}/xrpc/social.grain.gallery.getGallery?uri={}", 10 + base_url, 9 11 urlencoding::encode(gallery_uri) 10 12 ); 11 13 ··· 26 28 Ok(data) 27 29 } 28 30 29 - pub fn extract_thumbnails(gallery_data: &GalleryResponse, max_count: usize) -> Result<Vec<String>> { 30 - let thumb_urls: Vec<String> = gallery_data 31 - .items 32 - .iter() 33 - .filter_map(|item| { 34 - if !item.thumb.is_empty() { 35 - Some(item.thumb.clone()) 36 - } else { 37 - None 38 - } 39 - }) 40 - .take(max_count) 41 - .collect(); 42 - 43 - if thumb_urls.is_empty() { 44 - return Err(anyhow!("No thumbnail images found")); 45 - } 46 - 47 - Ok(thumb_urls) 48 - }
+14 -54
services/darkroom/src/html_generator.rs
··· 1 - use crate::types::{CompositeOptions, GalleryItem}; 2 1 use minijinja::{Environment, context}; 3 2 use std::include_str; 4 - 5 - fn is_portrait(item: &GalleryItem) -> bool { 6 - item.aspect_ratio.height > item.aspect_ratio.width 7 - } 3 + use anyhow::Result; 8 4 9 5 fn create_template_env() -> Environment<'static> { 10 6 let mut env = Environment::new(); 11 7 12 - // Add templates as strings (embedded at compile time) 8 + // Add adaptive layout template 13 9 env.add_template( 14 - "single_image.html", 15 - include_str!("../templates/single_image.html"), 16 - ) 17 - .unwrap(); 18 - env.add_template( 19 - "multi_image.html", 20 - include_str!("../templates/multi_image.html"), 10 + "adaptive_layout.html", 11 + include_str!("../templates/adaptive_layout.html"), 21 12 ) 22 13 .unwrap(); 23 14 24 15 env 25 16 } 26 17 27 - pub fn generate_grid_html(options: CompositeOptions) -> String { 28 - let CompositeOptions { 29 - items, 30 - title, 31 - handle, 32 - } = options; 33 18 19 + pub fn generate_adaptive_grid_html_with_uri(gallery_uri: &str, title: String, handle: String) -> Result<String> { 34 20 let env = create_template_env(); 21 + let template = env.get_template("adaptive_layout.html")?; 35 22 36 - match items.len() { 37 - 1 => { 38 - let is_portrait = is_portrait(&items[0]); 39 - let template = env.get_template("single_image.html").unwrap(); 40 - template 41 - .render(context! { 42 - item => &items[0], 43 - title => title, 44 - handle => handle, 45 - is_portrait => is_portrait 46 - }) 47 - .unwrap() 48 - } 49 - 2..=9 => { 50 - let template = env.get_template("multi_image.html").unwrap(); 51 - template 52 - .render(context! { 53 - items => &items, 54 - title => title, 55 - handle => handle 56 - }) 57 - .unwrap() 58 - } 59 - _ => { 60 - // Fallback for more than 9 images - use first 9 61 - let template = env.get_template("multi_image.html").unwrap(); 62 - template 63 - .render(context! { 64 - items => &items[..9], 65 - title => title, 66 - handle => handle 67 - }) 68 - .unwrap() 69 - } 70 - } 23 + let html = template.render(context! { 24 + gallery_uri => gallery_uri, 25 + title => title, 26 + handle => handle, 27 + })?; 28 + 29 + Ok(html) 71 30 } 31 +
+35 -20
services/darkroom/src/main.rs
··· 1 1 use axum::{ 2 2 body::Body, 3 3 extract::Query, 4 - http::{StatusCode, header}, 4 + http::StatusCode, 5 5 response::{Html, Response}, 6 6 routing::get, 7 7 Json, Router, ··· 9 9 use serde_json::json; 10 10 use std::collections::HashMap; 11 11 use tokio::net::TcpListener; 12 - use tower_http::{cors::CorsLayer, trace::TraceLayer}; 12 + use tower_http::{cors::CorsLayer, trace::TraceLayer, services::ServeDir}; 13 13 use tracing::{info, warn}; 14 14 15 15 mod composite_handler; ··· 19 19 mod screenshot_service; 20 20 mod types; 21 21 22 - use composite_handler::handle_composite_api; 23 - use preview_handler::handle_composite_preview; 22 + use composite_handler::handle_adaptive_composite_api; 23 + use preview_handler::handle_adaptive_preview; 24 + use gallery_service::fetch_gallery_data; 24 25 25 26 #[tokio::main] 26 27 async fn main() -> anyhow::Result<()> { ··· 29 30 30 31 let app = Router::new() 31 32 .route("/health", get(health_check)) 32 - .route("/composite-preview", get(preview_route)) 33 - .route("/xrpc/social.grain.darkroom.getGalleryComposite", get(api_route)) 34 - .route("/static/css/base.css", get(serve_css)) 33 + .route("/gallery-preview", get(adaptive_preview_route)) 34 + .route("/api/gallery", get(gallery_proxy_route)) 35 + .route("/xrpc/social.grain.darkroom.getGalleryComposite", get(adaptive_api_route)) 36 + .nest_service("/static", ServeDir::new("static")) 35 37 .layer(CorsLayer::permissive()) 36 38 .layer(TraceLayer::new_for_http()) 37 39 .fallback(not_found); 38 40 39 - let listener = TcpListener::bind("[::]:8080").await?; 40 - info!("Darkroom service listening on http://localhost:8080"); 41 + let port = std::env::var("PORT").unwrap_or_else(|_| "8080".to_string()); 42 + let bind_address = format!("[::]:{}", port); 43 + let listener = TcpListener::bind(&bind_address).await?; 44 + info!("Darkroom service listening on http://localhost:{}", port); 41 45 42 46 axum::serve(listener, app).await?; 43 47 Ok(()) 44 48 } 45 49 46 - async fn preview_route(Query(params): Query<HashMap<String, String>>) -> Result<Html<String>, StatusCode> { 47 - match handle_composite_preview(params) { 50 + async fn adaptive_preview_route(Query(params): Query<HashMap<String, String>>) -> Result<Html<String>, StatusCode> { 51 + match handle_adaptive_preview(params) { 48 52 Ok(html) => Ok(Html(html)), 49 53 Err(e) => { 50 54 warn!("Preview error: {}", e); ··· 53 57 } 54 58 } 55 59 56 - async fn api_route(Query(params): Query<HashMap<String, String>>) -> Result<Response<Body>, StatusCode> { 57 - match handle_composite_api(params).await { 60 + async fn adaptive_api_route(Query(params): Query<HashMap<String, String>>) -> Result<Response<Body>, StatusCode> { 61 + match handle_adaptive_composite_api(params).await { 58 62 Ok(response) => Ok(response), 59 63 Err(e) => { 60 64 warn!("API error: {}", e); ··· 70 74 ) 71 75 } 72 76 73 - async fn serve_css() -> Response<Body> { 74 - let css_content = include_str!("../static/css/base.css"); 75 - Response::builder() 76 - .status(StatusCode::OK) 77 - .header(header::CONTENT_TYPE, "text/css") 78 - .body(Body::from(css_content)) 79 - .unwrap() 77 + 78 + 79 + async fn gallery_proxy_route(Query(params): Query<HashMap<String, String>>) -> Result<axum::Json<serde_json::Value>, StatusCode> { 80 + let gallery_uri = params 81 + .get("uri") 82 + .ok_or(StatusCode::BAD_REQUEST)?; 83 + 84 + match fetch_gallery_data(gallery_uri).await { 85 + Ok(gallery_data) => { 86 + let json_value = serde_json::to_value(gallery_data) 87 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 88 + Ok(axum::Json(json_value)) 89 + }, 90 + Err(e) => { 91 + warn!("Gallery proxy error: {}", e); 92 + Err(StatusCode::INTERNAL_SERVER_ERROR) 93 + } 94 + } 80 95 } 81 96 82 97 async fn health_check() -> Json<serde_json::Value> {
+7 -32
services/darkroom/src/preview_handler.rs
··· 1 - use crate::html_generator::generate_grid_html; 2 - use crate::types::{AspectRatio, CompositeOptions, GalleryItem}; 1 + use crate::html_generator::generate_adaptive_grid_html_with_uri; 3 2 use anyhow::Result; 4 3 use std::collections::HashMap; 5 4 6 - pub fn handle_composite_preview(params: HashMap<String, String>) -> Result<String> { 7 - let thumbs_param = params.get("thumbs").unwrap_or(&String::new()).clone(); 8 - let thumb_urls: Vec<String> = if !thumbs_param.is_empty() { 9 - thumbs_param.split(',').map(|s| s.to_string()).collect() 10 - } else { 11 - Vec::new() 12 - }; 5 + pub fn handle_adaptive_preview(params: HashMap<String, String>) -> Result<String> { 6 + let gallery_uri = params.get("uri") 7 + .ok_or_else(|| anyhow::anyhow!("Missing uri parameter"))?; 13 8 14 - // Convert URLs to GalleryItem objects with default aspect ratios 15 - // Since we don't have actual aspect ratio data from URL params, use a default square ratio 16 - let items: Vec<GalleryItem> = thumb_urls 17 - .into_iter() 18 - .map(|thumb| GalleryItem { 19 - thumb, 20 - aspect_ratio: AspectRatio { 21 - width: 1.0, 22 - height: 1.0, 23 - }, 24 - extra: serde_json::Value::Null, 25 - }) 26 - .collect(); 9 + let title = params.get("title").cloned().unwrap_or_default(); 10 + let handle = params.get("handle").cloned().unwrap_or_default(); 27 11 28 - let title = params.get("title").unwrap_or(&String::new()).clone(); 29 - let handle = params.get("handle").unwrap_or(&String::new()).clone(); 30 - 31 - let options = CompositeOptions { 32 - items, 33 - title, 34 - handle, 35 - }; 36 - 37 - let html = generate_grid_html(options); 12 + let html = generate_adaptive_grid_html_with_uri(gallery_uri, title, handle)?; 38 13 Ok(html) 39 14 }
+40 -19
services/darkroom/src/screenshot_service.rs
··· 6 6 use tracing::info; 7 7 8 8 pub async fn capture_screenshot(preview_url: &str) -> Result<Vec<u8>> { 9 + capture_screenshot_with_size(preview_url, "1200,769").await 10 + } 11 + 12 + async fn capture_screenshot_with_size(preview_url: &str, window_size: &str) -> Result<Vec<u8>> { 9 13 info!("Starting screenshot capture for: {}", preview_url); 10 14 11 15 // Check if ChromeDriver is running on port 9515 ··· 46 50 "--no-sandbox", 47 51 "--disable-gpu", 48 52 "--disable-dev-shm-usage", 49 - "--window-size=1500,2139", 53 + &format!("--window-size={}", window_size), 50 54 "--font-render-hinting=medium", 51 55 "--enable-font-antialiasing", 52 56 ], ··· 80 84 .await 81 85 .map_err(|e| anyhow!("Failed to wait for fonts: {}", e))?; 82 86 83 - // Add a small delay to ensure rendering 84 - tokio::time::sleep(std::time::Duration::from_millis(300)).await; 87 + // Wait for screenshot ready signal (for dynamic content) or fallback to delay 88 + info!("Waiting for screenshot ready signal..."); 89 + let ready = client 90 + .execute( 91 + r#" 92 + return new Promise((resolve) => { 93 + // Check if already ready 94 + if (document.body.dataset.screenshotReady === 'true') { 95 + resolve(true); 96 + return; 97 + } 98 + 99 + // Wait for the ready signal with timeout 100 + const timeout = setTimeout(() => { 101 + console.log('Screenshot ready timeout - proceeding anyway'); 102 + resolve(false); 103 + }, 15000); // 15 second timeout 104 + 105 + document.addEventListener('screenshotReady', () => { 106 + clearTimeout(timeout); 107 + resolve(true); 108 + }); 109 + }); 110 + "#, 111 + vec![], 112 + ) 113 + .await 114 + .map_err(|e| anyhow!("Failed to wait for screenshot ready: {}", e))?; 115 + 116 + if ready.as_bool().unwrap_or(false) { 117 + info!("Screenshot ready signal received"); 118 + } else { 119 + info!("Screenshot ready timeout - using fallback delay"); 120 + tokio::time::sleep(std::time::Duration::from_millis(1000)).await; 121 + } 85 122 86 123 info!("Taking screenshot..."); 87 124 let screenshot_data = client ··· 107 144 108 145 result 109 146 } 110 - 111 - pub fn build_preview_url( 112 - base_url: &str, 113 - thumb_urls: &[String], 114 - title: &str, 115 - handle: &str, 116 - ) -> String { 117 - let thumbs_param = thumb_urls.join(","); 118 - format!( 119 - "{}/composite-preview?thumbs={}&title={}&handle={}", 120 - base_url, 121 - urlencoding::encode(&thumbs_param), 122 - urlencoding::encode(title), 123 - urlencoding::encode(handle) 124 - ) 125 - }
-6
services/darkroom/src/types.rs
··· 29 29 pub extra: serde_json::Value, 30 30 } 31 31 32 - #[derive(Debug, Clone)] 33 - pub struct CompositeOptions { 34 - pub items: Vec<GalleryItem>, 35 - pub title: String, 36 - pub handle: String, 37 - }
+77
services/darkroom/static/css/adaptive_layout.css
··· 1 + body { 2 + background: #fff; 3 + margin: 0; 4 + padding: 0; 5 + font-family: "DejaVu Sans", "Liberation Sans", Arial, sans-serif; 6 + position: relative; 7 + width: 1200px; 8 + height: 630px; 9 + } 10 + 11 + .content { 12 + position: absolute; 13 + top: 15px; 14 + left: 15px; 15 + width: 1170px; 16 + height: 480px; 17 + } 18 + 19 + .adaptive-grid { 20 + position: relative; 21 + width: 100%; 22 + height: 100%; 23 + } 24 + 25 + .adaptive-grid img { 26 + position: absolute; 27 + object-fit: cover; 28 + border-radius: 4px; 29 + } 30 + 31 + .footer { 32 + position: absolute; 33 + bottom: 0; 34 + left: 0; 35 + width: 1200px; 36 + height: 135px; 37 + display: flex; 38 + align-items: center; 39 + justify-content: space-between; 40 + padding: 0 15px; 41 + box-sizing: border-box; 42 + } 43 + 44 + .footer::before { 45 + content: ''; 46 + position: absolute; 47 + top: 0; 48 + left: 15px; 49 + right: 15px; 50 + height: 1px; 51 + background-color: #e4e4e7; 52 + } 53 + 54 + .title { 55 + font-weight: 400; 56 + color: #212529; 57 + line-height: 1.1; 58 + max-width: 600px; 59 + font-size: clamp(24px, 4vw, 48px); 60 + word-wrap: break-word; 61 + overflow-wrap: break-word; 62 + } 63 + 64 + .handle { 65 + font-size: 28px; 66 + font-weight: bold; 67 + color: #212529; 68 + line-height: 1.1; 69 + } 70 + 71 + .grain { 72 + font-size: 24px; 73 + font-weight: bold; 74 + color: #6c757d; 75 + line-height: 1.1; 76 + margin-top: 6px; 77 + }
-365
services/darkroom/static/css/base.css
··· 1 - body { 2 - background: #fff; 3 - margin: 0; 4 - padding: 0; 5 - width: 1500px; 6 - height: 2000px; 7 - font-family: "DejaVu Sans", "Liberation Sans", Arial, sans-serif; 8 - position: relative; 9 - } 10 - 11 - /* Content area - absolute positioned */ 12 - .content { 13 - position: absolute; 14 - top: 20px; 15 - left: 20px; 16 - width: 1460px; /* 1500px - 40px padding */ 17 - height: 1760px; /* 2000px - 40px padding - 200px footer */ 18 - } 19 - 20 - /* Footer fixed at bottom */ 21 - .footer { 22 - position: absolute; 23 - bottom: 0; 24 - left: 0; 25 - width: 1500px; 26 - height: 220px; /* 200px + 20px to center between grid end and bottom */ 27 - display: flex; 28 - align-items: center; 29 - justify-content: space-between; 30 - padding: 0 20px; 31 - box-sizing: border-box; 32 - } 33 - 34 - .title { 35 - font-weight: 400; 36 - color: #212529; 37 - line-height: 1.1; 38 - max-width: 800px; /* Reserve space for handle/grain on right */ 39 - font-size: clamp(32px, 6vw, 72px); /* Scales from 32px to 72px based on container width */ 40 - word-wrap: break-word; 41 - overflow-wrap: break-word; 42 - } 43 - 44 - .handle { 45 - font-size: 38px; 46 - font-weight: bold; 47 - color: #212529; 48 - line-height: 1.1; 49 - } 50 - 51 - .grain { 52 - font-size: 32px; 53 - font-weight: bold; 54 - color: #6c757d; 55 - line-height: 1.1; 56 - margin-top: 8px; 57 - } 58 - 59 - /* Single image - centered */ 60 - .single-image { 61 - width: 100%; 62 - height: 100%; 63 - display: flex; 64 - align-items: center; 65 - justify-content: center; 66 - } 67 - 68 - .single-image img { 69 - max-width: 100%; 70 - max-height: 100%; 71 - object-fit: cover; 72 - display: block; 73 - } 74 - 75 - /* Multi-image layouts using absolute positioning */ 76 - .multi-grid { 77 - position: relative; 78 - width: 100%; 79 - height: 100%; 80 - } 81 - 82 - .multi-grid img { 83 - position: absolute; 84 - object-fit: cover; 85 - } 86 - 87 - /* Two images: stacked vertically */ 88 - .grid-2 img:nth-child(1) { 89 - top: 0; 90 - left: 0; 91 - width: 1460px; 92 - height: 877px; 93 - } 94 - .grid-2 img:nth-child(2) { 95 - top: 882px; 96 - left: 0; 97 - width: 1460px; 98 - height: 878px; 99 - } 100 - 101 - /* Three images: 2 top, 1 bottom spanning full width */ 102 - .grid-3 img:nth-child(1) { 103 - top: 0; 104 - left: 0; 105 - width: 727px; 106 - height: 877px; 107 - } 108 - .grid-3 img:nth-child(2) { 109 - top: 0; 110 - left: 732px; 111 - width: 728px; 112 - height: 877px; 113 - } 114 - .grid-3 img:nth-child(3) { 115 - top: 882px; 116 - left: 0; 117 - width: 1460px; 118 - height: 878px; 119 - } 120 - 121 - /* Four images: 2x2 grid */ 122 - .grid-4 img:nth-child(1) { 123 - top: 0; 124 - left: 0; 125 - width: 727px; 126 - height: 877px; 127 - } 128 - .grid-4 img:nth-child(2) { 129 - top: 0; 130 - left: 732px; 131 - width: 728px; 132 - height: 877px; 133 - } 134 - .grid-4 img:nth-child(3) { 135 - top: 882px; 136 - left: 0; 137 - width: 727px; 138 - height: 878px; 139 - } 140 - .grid-4 img:nth-child(4) { 141 - top: 882px; 142 - left: 732px; 143 - width: 728px; 144 - height: 878px; 145 - } 146 - 147 - /* Five images: 2x2 + 1 bottom spanning full */ 148 - .grid-5 img:nth-child(1) { 149 - top: 0; 150 - left: 0; 151 - width: 727px; 152 - height: 583px; 153 - } 154 - .grid-5 img:nth-child(2) { 155 - top: 0; 156 - left: 732px; 157 - width: 728px; 158 - height: 583px; 159 - } 160 - .grid-5 img:nth-child(3) { 161 - top: 588px; 162 - left: 0; 163 - width: 727px; 164 - height: 583px; 165 - } 166 - .grid-5 img:nth-child(4) { 167 - top: 588px; 168 - left: 732px; 169 - width: 728px; 170 - height: 583px; 171 - } 172 - .grid-5 img:nth-child(5) { 173 - top: 1176px; 174 - left: 0; 175 - width: 1460px; 176 - height: 584px; 177 - } 178 - 179 - /* Six images: 2x3 grid */ 180 - .grid-6 img:nth-child(1) { 181 - top: 0; 182 - left: 0; 183 - width: 727px; 184 - height: 583px; 185 - } 186 - .grid-6 img:nth-child(2) { 187 - top: 0; 188 - left: 732px; 189 - width: 728px; 190 - height: 583px; 191 - } 192 - .grid-6 img:nth-child(3) { 193 - top: 588px; 194 - left: 0; 195 - width: 727px; 196 - height: 583px; 197 - } 198 - .grid-6 img:nth-child(4) { 199 - top: 588px; 200 - left: 732px; 201 - width: 728px; 202 - height: 583px; 203 - } 204 - .grid-6 img:nth-child(5) { 205 - top: 1176px; 206 - left: 0; 207 - width: 727px; 208 - height: 584px; 209 - } 210 - .grid-6 img:nth-child(6) { 211 - top: 1176px; 212 - left: 732px; 213 - width: 728px; 214 - height: 584px; 215 - } 216 - 217 - /* Seven images: 3x3 with last row having 1 image */ 218 - .grid-7 img:nth-child(1) { 219 - top: 0; 220 - left: 0; 221 - width: 483px; 222 - height: 583px; 223 - } 224 - .grid-7 img:nth-child(2) { 225 - top: 0; 226 - left: 488px; 227 - width: 484px; 228 - height: 583px; 229 - } 230 - .grid-7 img:nth-child(3) { 231 - top: 0; 232 - left: 977px; 233 - width: 483px; 234 - height: 583px; 235 - } 236 - .grid-7 img:nth-child(4) { 237 - top: 588px; 238 - left: 0; 239 - width: 483px; 240 - height: 583px; 241 - } 242 - .grid-7 img:nth-child(5) { 243 - top: 588px; 244 - left: 488px; 245 - width: 484px; 246 - height: 583px; 247 - } 248 - .grid-7 img:nth-child(6) { 249 - top: 588px; 250 - left: 977px; 251 - width: 483px; 252 - height: 583px; 253 - } 254 - .grid-7 img:nth-child(7) { 255 - top: 1176px; 256 - left: 0; 257 - width: 1460px; 258 - height: 584px; 259 - } 260 - 261 - /* Eight images: 3x3 with last row having 2 images */ 262 - .grid-8 img:nth-child(1) { 263 - top: 0; 264 - left: 0; 265 - width: 483px; 266 - height: 583px; 267 - } 268 - .grid-8 img:nth-child(2) { 269 - top: 0; 270 - left: 488px; 271 - width: 484px; 272 - height: 583px; 273 - } 274 - .grid-8 img:nth-child(3) { 275 - top: 0; 276 - left: 977px; 277 - width: 483px; 278 - height: 583px; 279 - } 280 - .grid-8 img:nth-child(4) { 281 - top: 588px; 282 - left: 0; 283 - width: 483px; 284 - height: 583px; 285 - } 286 - .grid-8 img:nth-child(5) { 287 - top: 588px; 288 - left: 488px; 289 - width: 484px; 290 - height: 583px; 291 - } 292 - .grid-8 img:nth-child(6) { 293 - top: 588px; 294 - left: 977px; 295 - width: 483px; 296 - height: 583px; 297 - } 298 - .grid-8 img:nth-child(7) { 299 - top: 1176px; 300 - left: 0; 301 - width: 727px; 302 - height: 584px; 303 - } 304 - .grid-8 img:nth-child(8) { 305 - top: 1176px; 306 - left: 732px; 307 - width: 728px; 308 - height: 584px; 309 - } 310 - 311 - /* Nine images: 3x3 grid */ 312 - .grid-9 img:nth-child(1) { 313 - top: 0; 314 - left: 0; 315 - width: 483px; 316 - height: 583px; 317 - } 318 - .grid-9 img:nth-child(2) { 319 - top: 0; 320 - left: 488px; 321 - width: 484px; 322 - height: 583px; 323 - } 324 - .grid-9 img:nth-child(3) { 325 - top: 0; 326 - left: 977px; 327 - width: 483px; 328 - height: 583px; 329 - } 330 - .grid-9 img:nth-child(4) { 331 - top: 588px; 332 - left: 0; 333 - width: 483px; 334 - height: 583px; 335 - } 336 - .grid-9 img:nth-child(5) { 337 - top: 588px; 338 - left: 488px; 339 - width: 484px; 340 - height: 583px; 341 - } 342 - .grid-9 img:nth-child(6) { 343 - top: 588px; 344 - left: 977px; 345 - width: 483px; 346 - height: 583px; 347 - } 348 - .grid-9 img:nth-child(7) { 349 - top: 1176px; 350 - left: 0; 351 - width: 483px; 352 - height: 584px; 353 - } 354 - .grid-9 img:nth-child(8) { 355 - top: 1176px; 356 - left: 488px; 357 - width: 484px; 358 - height: 584px; 359 - } 360 - .grid-9 img:nth-child(9) { 361 - top: 1176px; 362 - left: 977px; 363 - width: 483px; 364 - height: 584px; 365 - }
+312
services/darkroom/static/js/adaptive_layout.js
··· 1 + async function fetchGalleryData(galleryUri) { 2 + try { 3 + const galleryUrl = `/api/gallery?uri=${encodeURIComponent(galleryUri)}`; 4 + console.log(`Fetching gallery data from: ${galleryUrl}`); 5 + 6 + const response = await fetch(galleryUrl); 7 + if (!response.ok) { 8 + throw new Error(`HTTP ${response.status}: ${response.statusText}`); 9 + } 10 + return await response.json(); 11 + } catch (error) { 12 + console.error('Failed to fetch gallery data:', error); 13 + throw error; 14 + } 15 + } 16 + 17 + async function waitForImagesLoaded(images) { 18 + console.log(`Waiting for ${images.length} images to load...`); 19 + 20 + const imagePromises = images.map(img => { 21 + return new Promise((resolve) => { 22 + if (img.complete) { 23 + resolve(); 24 + } else { 25 + img.addEventListener('load', resolve); 26 + img.addEventListener('error', () => { 27 + console.warn(`Image failed to load: ${img.src}`); 28 + resolve(); // Still resolve to continue with layout 29 + }); 30 + 31 + // Timeout after 10 seconds 32 + setTimeout(() => { 33 + console.warn(`Image load timeout: ${img.src}`); 34 + resolve(); 35 + }, 10000); 36 + } 37 + }); 38 + }); 39 + 40 + await Promise.all(imagePromises); 41 + console.log('All images loaded'); 42 + } 43 + 44 + function signalScreenshotReady() { 45 + console.log('Signaling screenshot ready'); 46 + document.body.dataset.screenshotReady = 'true'; 47 + 48 + // Also dispatch a custom event for more flexibility 49 + const event = new CustomEvent('screenshotReady', { 50 + detail: { timestamp: Date.now() } 51 + }); 52 + document.dispatchEvent(event); 53 + } 54 + 55 + async function computeJustified() { 56 + const container = document.querySelector(".adaptive-grid"); 57 + if (!container) return; 58 + 59 + const spacing = 6; 60 + const containerWidth = container.offsetWidth; 61 + const containerHeight = container.offsetHeight; 62 + 63 + // Debug: Log container dimensions 64 + console.log(`Container dimensions: ${containerWidth}x${containerHeight}`); 65 + 66 + if (containerWidth === 0 || containerHeight === 0) { 67 + requestAnimationFrame(computeJustified); 68 + return; 69 + } 70 + 71 + // Check if we should fetch data client-side 72 + const galleryUri = container.dataset.galleryUri; 73 + if (galleryUri) { 74 + try { 75 + const galleryData = await fetchGalleryData(galleryUri); 76 + console.log(`Fetched ${galleryData.items.length} items from gallery`); 77 + 78 + // Create image elements and data from fetched gallery 79 + container.innerHTML = ''; // Clear existing content 80 + const imageData = galleryData.items.map((item, _index) => { 81 + const img = document.createElement('img'); 82 + img.src = item.thumb; 83 + img.alt = 'Gallery image'; 84 + img.dataset.width = item.aspectRatio?.width || item.width || 800; 85 + img.dataset.height = item.aspectRatio?.height || item.height || 600; 86 + container.appendChild(img); 87 + 88 + const width = parseFloat(img.dataset.width); 89 + const height = parseFloat(img.dataset.height); 90 + return { 91 + img, 92 + aspectRatio: width / height 93 + }; 94 + }); 95 + 96 + console.log(`Processing ${imageData.length} images`); 97 + 98 + // Calculate optimal layout that fills space while maintaining aspect ratios 99 + const layout = calculateOptimalLayout(imageData, containerWidth, containerHeight, spacing); 100 + console.log(`Selected layout with ${layout.rows.length} rows, efficiency: ${layout.efficiency}, space utilization: ${(layout.spaceUtilization * 100).toFixed(1)}%`); 101 + 102 + applyLayout(layout, container); 103 + 104 + // Wait for all images to load before signaling ready 105 + await waitForImagesLoaded(imageData.map(item => item.img)); 106 + signalScreenshotReady(); 107 + 108 + } catch (error) { 109 + console.error('Failed to load gallery:', error); 110 + container.innerHTML = '<div style="color: red; padding: 20px;">Failed to load gallery data</div>'; 111 + } 112 + } else { 113 + // Fallback to existing approach with pre-rendered images 114 + const images = Array.from(container.querySelectorAll("img")); 115 + if (images.length === 0) return; 116 + 117 + // Get image aspect ratios 118 + const imageData = images.map(img => { 119 + const width = parseFloat(img.dataset.width) || img.naturalWidth || 800; 120 + const height = parseFloat(img.dataset.height) || img.naturalHeight || 600; 121 + return { img, aspectRatio: width / height }; 122 + }); 123 + 124 + console.log(`Processing ${imageData.length} images`); 125 + 126 + // Calculate optimal layout that fills space while maintaining aspect ratios 127 + const layout = calculateOptimalLayout(imageData, containerWidth, containerHeight, spacing); 128 + console.log(`Selected layout with ${layout.rows.length} rows, efficiency: ${layout.efficiency}, space utilization: ${(layout.spaceUtilization * 100).toFixed(1)}%`); 129 + 130 + applyLayout(layout, container); 131 + 132 + // For pre-rendered images, wait for them to load then signal ready 133 + await waitForImagesLoaded(images); 134 + signalScreenshotReady(); 135 + } 136 + } 137 + 138 + function calculateOptimalLayout(imageData, containerWidth, containerHeight, spacing) { 139 + // Try different approaches and pick the best one 140 + const approaches = []; 141 + 142 + // Try 1 to N rows (or reasonable limit for performance) 143 + const maxRows = Math.min(imageData.length, Math.ceil(Math.sqrt(imageData.length)) + 3); 144 + for (let numRows = 1; numRows <= maxRows; numRows++) { 145 + const layout = tryRowLayout(imageData, numRows, containerWidth, containerHeight, spacing); 146 + if (layout) { 147 + // Calculate actual space utilization (width * height used) 148 + // For layouts that need scaling, calculate the actual space they'll use after scaling 149 + const actualMaxWidth = layout.maxWidth * layout.efficiency; 150 + const actualHeight = layout.totalHeight * layout.efficiency; 151 + layout.spaceUtilization = (actualMaxWidth / containerWidth) * (actualHeight / containerHeight); 152 + 153 + console.log(` ${numRows} rows: efficiency=${layout.efficiency.toFixed(3)}, max_width=${layout.maxWidth.toFixed(0)}, actual_width=${actualMaxWidth.toFixed(0)}, space=${(layout.spaceUtilization * 100).toFixed(1)}%`); 154 + approaches.push(layout); 155 + } 156 + } 157 + 158 + // Return the layout with the best space utilization 159 + return approaches.reduce((best, current) => { 160 + // Simply pick the layout with the highest space utilization 161 + return current.spaceUtilization > best.spaceUtilization ? current : best; 162 + }); 163 + } 164 + 165 + function tryRowLayout(imageData, numRows, containerWidth, containerHeight, spacing) { 166 + // Distribute images across rows maintaining original order 167 + const rows = Array.from({ length: numRows }, () => []); 168 + 169 + // Distribute images in original order with balanced aspect ratios 170 + const totalAspectRatio = imageData.reduce((sum, item) => sum + item.aspectRatio, 0); 171 + const targetAspectRatioPerRow = totalAspectRatio / numRows; 172 + 173 + let currentIndex = 0; 174 + let currentRowIndex = 0; 175 + 176 + while (currentIndex < imageData.length && currentRowIndex < numRows) { 177 + let currentRowAspectRatio = 0; 178 + 179 + // Add items to current row until we reach target aspect ratio or run out of items 180 + while (currentIndex < imageData.length && currentRowIndex < numRows) { 181 + const nextItem = imageData[currentIndex]; 182 + const wouldBeAspectRatio = currentRowAspectRatio + nextItem.aspectRatio; 183 + 184 + // If this is the last row, add all remaining items 185 + if (currentRowIndex === numRows - 1) { 186 + rows[currentRowIndex].push(nextItem); 187 + currentRowAspectRatio = wouldBeAspectRatio; 188 + currentIndex++; 189 + } 190 + // If adding this item would exceed target by more than not adding it, move to next row 191 + else if (rows[currentRowIndex].length > 0 && 192 + Math.abs(wouldBeAspectRatio - targetAspectRatioPerRow) > 193 + Math.abs(currentRowAspectRatio - targetAspectRatioPerRow)) { 194 + break; 195 + } 196 + // Add the item to current row 197 + else { 198 + rows[currentRowIndex].push(nextItem); 199 + currentRowAspectRatio = wouldBeAspectRatio; 200 + currentIndex++; 201 + } 202 + } 203 + 204 + currentRowIndex++; 205 + } 206 + 207 + // Filter out empty rows 208 + const nonEmptyRows = rows.filter(row => row.length > 0); 209 + 210 + // Calculate average height per row to fit container height 211 + const totalSpacingHeight = (nonEmptyRows.length - 1) * spacing; 212 + const availableHeightForRows = containerHeight - totalSpacingHeight; 213 + const averageRowHeight = availableHeightForRows / nonEmptyRows.length; 214 + 215 + // For each row, use the average height and calculate resulting width 216 + const rowData = nonEmptyRows.map(row => { 217 + const totalAspectRatio = row.reduce((sum, item) => sum + item.aspectRatio, 0); 218 + const resultingWidth = (averageRowHeight * totalAspectRatio) + (row.length - 1) * spacing; 219 + return { 220 + items: row, 221 + height: averageRowHeight, 222 + resultingWidth, 223 + totalAspectRatio 224 + }; 225 + }); 226 + 227 + // Check if any row exceeds container width 228 + const maxWidth = Math.max(...rowData.map(row => row.resultingWidth)); 229 + const efficiency = Math.min(1, containerWidth / maxWidth); 230 + 231 + return { 232 + rows: rowData.map(row => ({ 233 + items: row.items, 234 + height: row.height, 235 + totalAspectRatio: row.totalAspectRatio 236 + })), 237 + efficiency, 238 + totalHeight: containerHeight, 239 + maxWidth 240 + }; 241 + } 242 + 243 + function applyLayout(layout, container) { 244 + const spacing = 6; 245 + const containerHeight = container.offsetHeight; 246 + 247 + // Scale down if any row exceeds container width 248 + const scale = layout.efficiency; 249 + 250 + let yOffset = 0; 251 + 252 + layout.rows.forEach((row, rowIndex) => { 253 + let xOffset = 0; 254 + let rowWidth = 0; 255 + 256 + const finalRowHeight = row.height * scale; 257 + 258 + row.items.forEach(item => { 259 + const width = finalRowHeight * item.aspectRatio; 260 + 261 + Object.assign(item.img.style, { 262 + position: "absolute", 263 + top: `${yOffset}px`, 264 + left: `${xOffset}px`, 265 + width: `${width}px`, 266 + height: `${finalRowHeight}px`, 267 + }); 268 + 269 + xOffset += width + spacing; 270 + rowWidth += width; 271 + }); 272 + 273 + rowWidth += (row.items.length - 1) * spacing; 274 + console.log(`Row ${rowIndex}: height=${finalRowHeight.toFixed(2)}, width=${rowWidth.toFixed(2)}, max_width=${layout.maxWidth?.toFixed(2)}, efficiency=${layout.efficiency.toFixed(3)}`); 275 + 276 + yOffset += finalRowHeight + spacing; 277 + }); 278 + 279 + console.log(`Total layout height: ${yOffset - spacing}, container height: ${containerHeight}`); 280 + container.style.position = "relative"; 281 + container.style.height = `${yOffset - spacing}px`; 282 + } 283 + 284 + // Run layout when page loads and images are loaded 285 + document.addEventListener("DOMContentLoaded", function () { 286 + // Wait for images to load to get natural dimensions 287 + const images = document.querySelectorAll(".adaptive-grid img"); 288 + let loadedCount = 0; 289 + 290 + function checkAllLoaded() { 291 + loadedCount++; 292 + if (loadedCount === images.length) { 293 + computeJustified(); 294 + } 295 + } 296 + 297 + if (images.length === 0) { 298 + computeJustified(); 299 + } else { 300 + images.forEach((img) => { 301 + if (img.complete) { 302 + checkAllLoaded(); 303 + } else { 304 + img.addEventListener("load", checkAllLoaded); 305 + img.addEventListener("error", checkAllLoaded); // Still layout even if image fails 306 + } 307 + }); 308 + } 309 + }); 310 + 311 + // Re-run layout on window resize 312 + globalThis.addEventListener("resize", computeJustified);
+26
services/darkroom/templates/adaptive_layout.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <title>Adaptive Gallery Preview</title> 6 + <link rel="stylesheet" href="/static/css/adaptive_layout.css"> 7 + <script src="/static/js/adaptive_layout.js"></script> 8 + </head> 9 + <body> 10 + <div class="content"> 11 + <div class="adaptive-grid" data-gallery-uri="{{ gallery_uri }}"></div> 12 + </div> 13 + 14 + {% if title or handle %} 15 + <div class="footer"> 16 + <span class="title">{{ title }}</span> 17 + <div style="text-align: right"> 18 + {% if handle %} 19 + <div class="handle">@{{ handle }}</div> 20 + {% endif %} 21 + <div class="grain">grain.social</div> 22 + </div> 23 + </div> 24 + {% endif %} 25 + </body> 26 + </html>
-29
services/darkroom/templates/multi_image.html
··· 1 - <!DOCTYPE html> 2 - <html lang="en"> 3 - <head> 4 - <meta charset="UTF-8"> 5 - <title>Composite Preview</title> 6 - <link rel="stylesheet" href="/static/css/base.css"> 7 - </head> 8 - <body> 9 - <div class="content"> 10 - <div class="multi-grid grid-{{ items|length }}"> 11 - {% for item in items %} 12 - <img src="{{ item.thumb }}" /> 13 - {% endfor %} 14 - </div> 15 - </div> 16 - 17 - {% if title or handle %} 18 - <div class="footer"> 19 - <span class="title">{{ title }}</span> 20 - <div style="text-align: right;"> 21 - {% if handle %} 22 - <div class="handle">@{{ handle }}</div> 23 - {% endif %} 24 - <div class="grain">grain.social</div> 25 - </div> 26 - </div> 27 - {% endif %} 28 - </body> 29 - </html>
-27
services/darkroom/templates/single_image.html
··· 1 - <!DOCTYPE html> 2 - <html lang="en"> 3 - <head> 4 - <meta charset="UTF-8"> 5 - <title>Composite Preview</title> 6 - <link rel="stylesheet" href="/static/css/base.css"> 7 - </head> 8 - <body> 9 - <div class="content"> 10 - <div class="single-image"> 11 - <img src="{{ item.thumb }}" /> 12 - </div> 13 - </div> 14 - 15 - {% if title or handle %} 16 - <div class="footer"> 17 - <span class="title">{{ title }}</span> 18 - <div style="text-align: right;"> 19 - {% if handle %} 20 - <div class="handle">@{{ handle }}</div> 21 - {% endif %} 22 - <div class="grain">grain.social</div> 23 - </div> 24 - </div> 25 - {% endif %} 26 - </body> 27 - </html>
+34
src/components/ShareGalleryDialog.tsx
··· 18 18 publicLink, 19 19 ) 20 20 }`; 21 + const darkroomServiceUrl = Deno.env.get("DARKROOM_HOST_URL") || ""; 22 + const compositeImageUrl = 23 + `${darkroomServiceUrl}/xrpc/social.grain.darkroom.getGalleryComposite?uri=${ 24 + encodeURIComponent(gallery.uri) 25 + }`; 21 26 return ( 22 27 <Dialog> 23 28 <Dialog.Content class="gap-4"> 24 29 <Dialog.Title>Share gallery</Dialog.Title> 25 30 <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 26 31 32 + <div class="w-full flex justify-center"> 33 + <div class="relative"> 34 + <div 35 + id="image-loader" 36 + class="flex items-center justify-center h-32 w-full" 37 + > 38 + <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-zinc-900 dark:border-zinc-100"> 39 + </div> 40 + </div> 41 + <img 42 + src={compositeImageUrl} 43 + alt="Gallery preview" 44 + class="max-w-full h-auto border border-zinc-200 dark:border-zinc-800 hidden" 45 + _="on load add .hidden to #image-loader then remove .hidden from me 46 + on error put 'Failed to load image' into #image-loader then add .text-red-500 to #image-loader" 47 + /> 48 + </div> 49 + </div> 50 + 27 51 <ul class="divide-y divide-zinc-200 dark:divide-zinc-800 border-t border-b border-zinc-200 dark:border-zinc-800"> 28 52 <li class="w-full hover:bg-zinc-200 dark:hover:bg-zinc-800"> 29 53 <a ··· 45 69 <i class="fa-solid fa-link"></i> 46 70 Copy link 47 71 </button> 72 + </li> 73 + <li class="w-full hover:bg-zinc-200 dark:hover:bg-zinc-800"> 74 + <a 75 + href={compositeImageUrl} 76 + download="gallery-preview.jpg" 77 + class="flex gap-2 justify-start items-center text-left w-full px-2 py-4 cursor-pointer" 78 + > 79 + <i class="fa-solid fa-download"></i> 80 + Save preview 81 + </a> 48 82 </li> 49 83 </ul> 50 84 <Dialog.Close variant="secondary">Close</Dialog.Close>
+5 -2
src/meta.ts
··· 1 1 import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 2 - import { isPhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 3 2 import { AtUri } from "@atproto/syntax"; 4 3 import { MetaDescriptor } from "@bigmoves/bff/components"; 5 4 import { PUBLIC_URL } from "./env.ts"; ··· 35 34 }, 36 35 { 37 36 property: "og:image", 38 - content: gallery?.items?.filter(isPhotoView)?.[0]?.fullsize, 37 + content: `${ 38 + Deno.env.get("DARKROOM_HOST_URL") || "" 39 + }/xrpc/social.grain.darkroom.getGalleryComposite?uri=${ 40 + encodeURIComponent(gallery.uri) 41 + }&social=true`, 39 42 }, 40 43 ]; 41 44 }