Files
Astronome/backend/src/api/gallery.rs
T
arnaudne 2bb80a8475 Add target comparison modal, integration goal progress, and session planning + full catalog expansion
Features added this session:
- Target comparison: side-by-side overlay (CompareModal) from Targets page via ⊕ button on each row; shows altitude curves, key times, filter recommendations and per-filter integration progress for two targets simultaneously
- Integration goal progress dashboard card: per-target keeper minutes vs goal hours (from CLAUDE.md §16.3) broken down by filter, with color-coded progress bars; powered by new stats.integration_goals backend query
- Session planning timeline: Gantt-style "Plan Tonight" section on Dashboard (PlanningTimeline component) — search targets, set durations, sequential scheduling from dusk, overrun warnings, clipboard export
- Slew-optimized run order toggle (nearest-neighbor sort by RA/Dec angular distance)
- Best Nights 14-day card + Monthly Highlights card on Dashboard

Catalog expansions:
- Sharpless (Sh2), VdB, LDN, Barnard dark nebulae, LBN, Melotte, Collinder, Gum, RCW, Abell PN, Abell GC, PGC bright subset
- Caldwell/Arp/Melotte/Collinder number columns + cross-reference maps
- Weather score multiplier applied to composite sort
- galaxy_cluster type (ACO badge) throughout TypeBadge, CSS, filter chips

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 07:20:10 +02:00

186 lines
6.5 KiB
Rust

use axum::{
extract::{Multipart, Path, State},
Json,
};
use std::path::PathBuf;
use super::{AppError, AppState};
const GALLERY_DIR: &str = "/data/gallery";
pub async fn list_all_gallery(
State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, AppError> {
let rows = sqlx::query(
r#"SELECT g.id, g.catalog_id, g.filename, g.caption, g.created_at,
c.name AS target_name, c.common_name AS target_common_name
FROM gallery g
LEFT JOIN catalog c ON c.id = g.catalog_id
ORDER BY g.created_at DESC"#,
)
.fetch_all(&state.pool)
.await?;
let items: Vec<serde_json::Value> = rows.iter().map(|r| {
use sqlx::Row;
let id: i32 = r.try_get("id").unwrap_or_default();
let catalog_id: String = r.try_get("catalog_id").unwrap_or_default();
let filename: String = r.try_get("filename").unwrap_or_default();
serde_json::json!({
"id": id,
"catalog_id": &catalog_id,
"filename": &filename,
"url": format!("/api/gallery/files/{}/{}", catalog_id, filename),
"caption": r.try_get::<Option<String>, _>("caption").unwrap_or_default(),
"created_at": r.try_get::<i64, _>("created_at").unwrap_or_default(),
"target_name": r.try_get::<Option<String>, _>("target_name").unwrap_or_default(),
"target_common_name": r.try_get::<Option<String>, _>("target_common_name").unwrap_or_default(),
})
}).collect();
Ok(Json(serde_json::json!({ "items": items })))
}
pub async fn list_gallery(
State(state): State<AppState>,
Path(catalog_id): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
let rows = sqlx::query(
"SELECT * FROM gallery WHERE catalog_id = ? ORDER BY created_at DESC",
)
.bind(&catalog_id)
.fetch_all(&state.pool)
.await?;
let items: Vec<serde_json::Value> = rows.iter().map(|r| {
use sqlx::Row;
let id: i32 = r.try_get("id").unwrap_or_default();
let filename: String = r.try_get("filename").unwrap_or_default();
serde_json::json!({
"id": id,
"catalog_id": catalog_id,
"filename": filename,
"url": format!("/api/gallery/files/{}/{}", catalog_id, filename),
"caption": r.try_get::<Option<String>, _>("caption").unwrap_or_default(),
"created_at": r.try_get::<i64, _>("created_at").unwrap_or_default(),
})
}).collect();
Ok(Json(serde_json::json!({ "items": items })))
}
pub async fn upload_image(
State(state): State<AppState>,
Path(catalog_id): Path<String>,
mut multipart: Multipart,
) -> Result<Json<serde_json::Value>, AppError> {
let mut image_bytes: Option<Vec<u8>> = None;
let mut orig_filename = String::from("image.jpg");
let mut caption: Option<String> = None;
let mut log_id: Option<i32> = None;
while let Some(field) = multipart.next_field().await.map_err(|e| AppError::Internal(e.to_string()))? {
let name = field.name().unwrap_or("").to_string();
match name.as_str() {
"file" => {
orig_filename = field.file_name().unwrap_or("image.jpg").to_string();
let bytes = field.bytes().await.map_err(|e| AppError::Internal(e.to_string()))?;
if bytes.len() > 50 * 1024 * 1024 {
return Err(AppError::BadRequest("File exceeds 50MB limit".to_string()));
}
image_bytes = Some(bytes.to_vec());
}
"caption" => {
caption = Some(field.text().await.map_err(|e| AppError::Internal(e.to_string()))?);
}
"log_id" => {
log_id = field.text().await.ok().and_then(|s| s.parse().ok());
}
_ => {}
}
}
let bytes = image_bytes.ok_or_else(|| AppError::BadRequest("No file uploaded".to_string()))?;
// Convert TIFF to JPEG if needed, else store as-is
let ext = std::path::Path::new(&orig_filename)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("jpg")
.to_lowercase();
let (final_bytes, final_ext) = if ext == "tiff" || ext == "tif" {
match convert_tiff_to_jpeg(&bytes) {
Ok(jpeg) => (jpeg, "jpg".to_string()),
Err(_) => (bytes, ext),
}
} else {
(bytes, ext)
};
// Generate unique filename
let uid = uuid::Uuid::new_v4();
let filename = format!("{}.{}", uid, final_ext);
// Ensure directory exists
let dir = PathBuf::from(GALLERY_DIR).join(&catalog_id);
tokio::fs::create_dir_all(&dir)
.await
.map_err(|e| AppError::Internal(format!("Failed to create gallery dir: {}", e)))?;
let file_path = dir.join(&filename);
tokio::fs::write(&file_path, &final_bytes)
.await
.map_err(|e| AppError::Internal(format!("Failed to write image: {}", e)))?;
let id: i64 = sqlx::query_scalar(
"INSERT INTO gallery (catalog_id, log_id, filename, caption) VALUES (?, ?, ?, ?) RETURNING id",
)
.bind(&catalog_id)
.bind(log_id)
.bind(&filename)
.bind(&caption)
.fetch_one(&state.pool)
.await?;
Ok(Json(serde_json::json!({
"id": id,
"catalog_id": catalog_id,
"filename": filename,
"url": format!("/api/gallery/files/{}/{}", catalog_id, filename),
})))
}
pub async fn delete_image(
State(state): State<AppState>,
Path(id): Path<i32>,
) -> Result<Json<serde_json::Value>, AppError> {
let row = sqlx::query("SELECT catalog_id, filename FROM gallery WHERE id = ?")
.bind(id)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("Gallery image {} not found", id)))?;
use sqlx::Row;
let catalog_id: String = row.try_get("catalog_id").unwrap_or_default();
let filename: String = row.try_get("filename").unwrap_or_default();
let file_path = PathBuf::from(GALLERY_DIR).join(&catalog_id).join(&filename);
let _ = tokio::fs::remove_file(&file_path).await;
sqlx::query("DELETE FROM gallery WHERE id = ?")
.bind(id)
.execute(&state.pool)
.await?;
Ok(Json(serde_json::json!({ "id": id, "status": "deleted" })))
}
fn convert_tiff_to_jpeg(bytes: &[u8]) -> anyhow::Result<Vec<u8>> {
let img = image::load_from_memory(bytes)?;
let mut output = Vec::new();
let mut cursor = std::io::Cursor::new(&mut output);
img.write_to(&mut cursor, image::ImageOutputFormat::Jpeg(90))?;
Ok(output)
}