2bb80a8475
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>
186 lines
6.5 KiB
Rust
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)
|
|
}
|