feat: upload drag-drop + responsive mobile

Backend:
- Route POST /api/upload avec multipart (axum 0.8)
- Proxy vers Imago, validation MIME, feedback JSON

Frontend:
- Zone de drop avec drag & drop (effet visuel au survol)
- Bouton Upload dans le header + FAB flottant mobile
- Support Ctrl+V (coller depuis presse-papier)
- Upload multiple, feedback toast (succès/erreur)
- Raccourci Ctrl+U pour upload
- Responsive: 2 breakpoints (768px, 400px)
- Gallery 2 colonnes sur mobile, FAB, toasts bottom
- user-scalable=no, tap-highlight transparent
This commit is contained in:
2026-06-23 10:31:02 -04:00
parent 29b4390844
commit f25174f143
4 changed files with 349 additions and 34 deletions
+62 -2
View File
@@ -1,8 +1,8 @@
//! Routes API REST du dashboard — proxy vers Imago v2
use axum::{
Router, routing::get,
extract::{Path, Query, State},
Router, routing::{get, post},
extract::{Multipart, Path, Query, State},
response::IntoResponse,
Json,
};
@@ -55,6 +55,7 @@ pub fn api_routes(state: AppState) -> Router {
let state = Arc::new(state);
Router::new()
.route("/api/health", get(health_check))
.route("/api/upload", post(upload_image))
.route("/api/images", get(list_images))
.route("/api/images/search", get(search_images))
.route("/api/images/{id}", get(get_image))
@@ -69,6 +70,65 @@ async fn health_check() -> &'static str {
"OK"
}
/// Upload d'image : reçoit un fichier et le transmet à l'API Imago
async fn upload_image(
State(state): State<Arc<AppState>>,
mut multipart: Multipart,
) -> impl IntoResponse {
let mut data: Vec<u8> = Vec::new();
let mut filename = String::from("upload.png");
let mut mime_type = String::from("image/png");
while let Ok(Some(mut field)) = multipart.next_field().await {
let name = field.name().unwrap_or("").to_string();
if name == "file" {
if let Some(fn_) = field.file_name() {
filename = fn_.to_string();
}
if let Some(mt) = field.content_type() {
mime_type = mt.to_string();
}
while let Ok(Some(chunk)) = field.chunk().await {
data.extend_from_slice(&chunk);
}
}
}
if data.is_empty() {
return (
axum::http::StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Aucun fichier reçu"})),
);
}
// Validation MIME basique côté dashboard
if !mime_type.starts_with("image/") {
return (
axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
Json(serde_json::json!({"error": format!("Type MIME non supporté : {}", mime_type)})),
);
}
match state.imago.upload_image(data, &filename, None).await {
Ok(response) => (
axum::http::StatusCode::CREATED,
Json(serde_json::json!({
"id": response.id,
"uuid": response.uuid,
"filename": response.original_name,
"status": response.status,
})),
),
Err(e) => {
tracing::error!("Upload vers Imago échoué : {:#}", e);
(
axum::http::StatusCode::BAD_GATEWAY,
Json(serde_json::json!({"error": format!("Échec de l'upload : {:#}", e)})),
)
}
}
}
async fn list_images(
State(state): State<Arc<AppState>>,
Query(pagination): Query<Pagination>,