feat: frontend MVP — auth, timeline, upload, albums, admin, image viewer
Backend: - user roles (DB + JWT + first-user-is-admin) - volume-aware file resolver (multi-volume asset serving) - directory scanner uses volume URI directly - date-summary endpoint (capture date from EXIF) - timeline ordered by capture date - list endpoints: volumes, plugins, pipelines, library paths - delete endpoints: volumes, library paths - configurable upload body limit (MAX_UPLOAD_BYTES) Frontend: - auth: login/register, token refresh, role-based admin gate - timeline: date-grouped grid, infinite scroll, date scrubber - image viewer: fullscreen zoom/pan/pinch, metadata sidebar - upload: drag-drop, sequential upload, progress tracking - albums: create, add/remove photos, asset picker dialog - admin: storage (import library), jobs (pagination, error details), plugins (list + toggle), pipelines, sidecars, duplicates - multi-select mode with add-to-album action - TanStack Query for all data fetching
This commit is contained in:
@@ -7,11 +7,14 @@ use crate::{
|
||||
};
|
||||
use api_types::{
|
||||
requests::{RegisterAssetRequest, TagAssetRequest},
|
||||
responses::{AssetResponse, IngestResponse, TagResponse, TimelineResponse},
|
||||
responses::{
|
||||
AssetResponse, DateCountEntry, DateSummaryResponse, IngestResponse, TagResponse,
|
||||
TimelineResponse,
|
||||
},
|
||||
};
|
||||
use application::{
|
||||
catalog::{
|
||||
DeleteAssetCommand, GetAssetQuery, GetTimelineQuery, ReadAssetFileQuery,
|
||||
DeleteAssetCommand, GetAssetQuery, GetDateSummaryQuery, GetTimelineQuery, ReadAssetFileQuery,
|
||||
ReadDerivativeQuery, RegisterAssetCommand, SearchAssetsQuery, UpdateMetadataCommand,
|
||||
},
|
||||
organization::TagAssetCommand,
|
||||
@@ -225,6 +228,25 @@ pub async fn timeline(
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn date_summary(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
) -> Result<Json<DateSummaryResponse>, AppError> {
|
||||
let query = GetDateSummaryQuery {
|
||||
owner_id: claims.user_id,
|
||||
};
|
||||
let entries = state.catalog.get_date_summary.execute(query).await?;
|
||||
Ok(Json(DateSummaryResponse {
|
||||
dates: entries
|
||||
.into_iter()
|
||||
.map(|e| DateCountEntry {
|
||||
date: e.date.to_string(),
|
||||
count: e.count,
|
||||
})
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put, path = "/api/v1/assets/{id}/metadata",
|
||||
request_body = api_types::requests::UpdateMetadataRequest,
|
||||
|
||||
@@ -34,7 +34,7 @@ pub async fn register(
|
||||
let user = state.identity.register.execute(cmd).await?;
|
||||
let token = state
|
||||
.token_issuer
|
||||
.issue(&user.id, "user")
|
||||
.issue(&user.id, &user.role)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
let (refresh_token, _) =
|
||||
|
||||
@@ -198,6 +198,15 @@ pub async fn batch_progress(
|
||||
Ok(Json(BatchProgressResponse::from_domain(&progress)))
|
||||
}
|
||||
|
||||
pub async fn list_plugins(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
) -> Result<Json<Vec<PluginResponse>>, AppError> {
|
||||
super::require_admin(&claims)?;
|
||||
let plugins = state.processing.list_plugins.execute().await?;
|
||||
Ok(Json(plugins.iter().map(PluginResponse::from_domain).collect()))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/plugins",
|
||||
request_body = ManagePluginRequest,
|
||||
@@ -251,6 +260,15 @@ pub async fn manage_plugin(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn list_pipelines(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
) -> Result<Json<Vec<PipelineResponse>>, AppError> {
|
||||
super::require_admin(&claims)?;
|
||||
let pipelines = state.processing.list_pipelines.execute().await?;
|
||||
Ok(Json(pipelines.iter().map(PipelineResponse::from_domain).collect()))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/pipelines",
|
||||
request_body = ConfigurePipelineRequest,
|
||||
|
||||
@@ -3,14 +3,35 @@ use api_types::{
|
||||
requests::{CheckQuotaParams, RegisterLibraryPathRequest, RegisterVolumeRequest},
|
||||
responses::{LibraryPathResponse, QuotaCheckResponse, VolumeResponse},
|
||||
};
|
||||
use application::storage::{CheckQuotaQuery, RegisterLibraryPathCommand, RegisterVolumeCommand};
|
||||
use application::storage::{
|
||||
CheckQuotaQuery, ListIngestPathsQuery, RegisterLibraryPathCommand, RegisterVolumeCommand,
|
||||
};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use domain::value_objects::SystemId;
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/storage/volumes",
|
||||
security(("bearer_token" = [])),
|
||||
responses(
|
||||
(status = 200, description = "All volumes", body = Vec<VolumeResponse>),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
pub async fn list_volumes(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
) -> Result<Json<Vec<VolumeResponse>>, AppError> {
|
||||
super::require_admin(&claims)?;
|
||||
let volumes = state.storage.list_volumes.execute().await?;
|
||||
Ok(Json(
|
||||
volumes.iter().map(VolumeResponse::from_domain).collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/storage/volumes",
|
||||
request_body = RegisterVolumeRequest,
|
||||
@@ -66,6 +87,75 @@ pub async fn register_library_path(
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/storage/library-paths",
|
||||
security(("bearer_token" = [])),
|
||||
responses(
|
||||
(status = 200, description = "Ingest destinations", body = Vec<LibraryPathResponse>),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
pub async fn list_ingest_paths(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
) -> Result<Json<Vec<LibraryPathResponse>>, AppError> {
|
||||
let query = ListIngestPathsQuery {
|
||||
user_id: claims.user_id,
|
||||
};
|
||||
let paths = state.storage.list_ingest_paths.execute(query).await?;
|
||||
Ok(Json(
|
||||
paths.iter().map(LibraryPathResponse::from_domain).collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/storage/library-paths/all",
|
||||
security(("bearer_token" = [])),
|
||||
responses(
|
||||
(status = 200, description = "All library paths", body = Vec<LibraryPathResponse>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden")
|
||||
)
|
||||
)]
|
||||
pub async fn list_all_library_paths(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
) -> Result<Json<Vec<LibraryPathResponse>>, AppError> {
|
||||
super::require_admin(&claims)?;
|
||||
let paths = state.storage.list_all_library_paths.execute().await?;
|
||||
Ok(Json(
|
||||
paths.iter().map(LibraryPathResponse::from_domain).collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn delete_volume(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
Path((id,)): Path<(uuid::Uuid,)>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
super::require_admin(&claims)?;
|
||||
state
|
||||
.storage
|
||||
.delete_volume
|
||||
.execute(SystemId::from_uuid(id))
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn delete_library_path(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
Path((id,)): Path<(uuid::Uuid,)>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
super::require_admin(&claims)?;
|
||||
state
|
||||
.storage
|
||||
.delete_library_path
|
||||
.execute(SystemId::from_uuid(id))
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
const DEFAULT_QUOTA_USAGE_TYPE: &str = "storage_bytes";
|
||||
const DEFAULT_QUOTA_AMOUNT: u64 = 0;
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ pub fn routes() -> Router<AppState> {
|
||||
.route("/assets/ingest", post(assets::ingest))
|
||||
.route("/assets/register", post(assets::register_asset))
|
||||
.route("/assets/timeline", get(assets::timeline))
|
||||
.route("/assets/date-summary", get(assets::date_summary))
|
||||
.route(
|
||||
"/assets/{id}",
|
||||
get(assets::get_asset).delete(assets::delete_asset),
|
||||
|
||||
@@ -14,6 +14,6 @@ pub fn routes() -> Router<AppState> {
|
||||
.route("/jobs/{id}/complete", post(processing::complete_job))
|
||||
.route("/jobs/{id}/fail", post(processing::fail_job))
|
||||
.route("/jobs/batches/{id}", get(processing::batch_progress))
|
||||
.route("/plugins", post(processing::manage_plugin))
|
||||
.route("/pipelines", post(processing::configure_pipeline))
|
||||
.route("/plugins", get(processing::list_plugins).post(processing::manage_plugin))
|
||||
.route("/pipelines", get(processing::list_pipelines).post(processing::configure_pipeline))
|
||||
}
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
use crate::{handlers::storage, state::AppState};
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{get, post},
|
||||
routing::{delete, get, post},
|
||||
};
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/storage/volumes", post(storage::register_volume))
|
||||
.route(
|
||||
"/storage/volumes",
|
||||
get(storage::list_volumes).post(storage::register_volume),
|
||||
)
|
||||
.route("/storage/volumes/{id}", delete(storage::delete_volume))
|
||||
.route(
|
||||
"/storage/library-paths",
|
||||
post(storage::register_library_path),
|
||||
get(storage::list_ingest_paths).post(storage::register_library_path),
|
||||
)
|
||||
.route(
|
||||
"/storage/library-paths/all",
|
||||
get(storage::list_all_library_paths),
|
||||
)
|
||||
.route(
|
||||
"/storage/library-paths/{id}",
|
||||
delete(storage::delete_library_path),
|
||||
)
|
||||
.route("/storage/quota", get(storage::check_quota))
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ use std::sync::Arc;
|
||||
use application::{
|
||||
catalog::{
|
||||
CreateStackHandler, DeleteAssetHandler, DeleteStackHandler, DetectLivePhotosHandler,
|
||||
GetAssetHandler, GetStackHandler, GetTimelineHandler, ListDuplicatesHandler,
|
||||
ListStacksHandler, ReadAssetFileHandler, ReadDerivativeHandler, RegisterAssetHandler,
|
||||
ResolveDuplicateHandler, SearchAssetsHandler, UpdateMetadataHandler,
|
||||
GetAssetHandler, GetDateSummaryHandler, GetStackHandler, GetTimelineHandler,
|
||||
ListDuplicatesHandler, ListStacksHandler, ReadAssetFileHandler, ReadDerivativeHandler,
|
||||
RegisterAssetHandler, ResolveDuplicateHandler, SearchAssetsHandler,
|
||||
UpdateMetadataHandler,
|
||||
},
|
||||
identity::{
|
||||
GetProfileHandler, LoginUserHandler, LogoutHandler, RefreshTokenHandler,
|
||||
@@ -17,7 +18,8 @@ use application::{
|
||||
},
|
||||
processing::{
|
||||
CompleteJobHandler, ConfigurePipelineHandler, EnqueueJobHandler, FailJobHandler,
|
||||
ListJobsHandler, ManagePluginHandler, ReportBatchProgressHandler, StartJobHandler,
|
||||
ListJobsHandler, ListPipelinesHandler, ListPluginsHandler, ManagePluginHandler,
|
||||
ReportBatchProgressHandler, StartJobHandler,
|
||||
},
|
||||
sharing::{
|
||||
AccessSharedResourceHandler, GenerateShareLinkHandler, RevokeShareHandler,
|
||||
@@ -28,7 +30,9 @@ use application::{
|
||||
ImportSidecarHandler, ResolveConflictHandler,
|
||||
},
|
||||
storage::{
|
||||
CheckQuotaHandler, IngestAssetHandler, RegisterLibraryPathHandler, RegisterVolumeHandler,
|
||||
CheckQuotaHandler, DeleteLibraryPathHandler, DeleteVolumeHandler, IngestAssetHandler,
|
||||
ListAllLibraryPathsHandler, ListIngestPathsHandler, ListVolumesHandler,
|
||||
RegisterLibraryPathHandler, RegisterVolumeHandler,
|
||||
},
|
||||
};
|
||||
use domain::ports::{RefreshTokenRepository, TokenIssuer};
|
||||
@@ -48,6 +52,7 @@ pub struct CatalogHandlers {
|
||||
pub ingest_asset: Arc<IngestAssetHandler>,
|
||||
pub get_asset: Arc<GetAssetHandler>,
|
||||
pub get_timeline: Arc<GetTimelineHandler>,
|
||||
pub get_date_summary: Arc<GetDateSummaryHandler>,
|
||||
pub update_metadata: Arc<UpdateMetadataHandler>,
|
||||
pub read_asset_file: Arc<ReadAssetFileHandler>,
|
||||
pub read_derivative: Arc<ReadDerivativeHandler>,
|
||||
@@ -76,7 +81,12 @@ pub struct OrganizationHandlers {
|
||||
#[derive(Clone)]
|
||||
pub struct StorageHandlers {
|
||||
pub register_volume: Arc<RegisterVolumeHandler>,
|
||||
pub delete_volume: Arc<DeleteVolumeHandler>,
|
||||
pub list_volumes: Arc<ListVolumesHandler>,
|
||||
pub register_library_path: Arc<RegisterLibraryPathHandler>,
|
||||
pub list_ingest_paths: Arc<ListIngestPathsHandler>,
|
||||
pub list_all_library_paths: Arc<ListAllLibraryPathsHandler>,
|
||||
pub delete_library_path: Arc<DeleteLibraryPathHandler>,
|
||||
pub check_quota: Arc<CheckQuotaHandler>,
|
||||
}
|
||||
|
||||
@@ -107,7 +117,9 @@ pub struct ProcessingHandlers {
|
||||
pub list_jobs: Arc<ListJobsHandler>,
|
||||
pub batch_progress: Arc<ReportBatchProgressHandler>,
|
||||
pub manage_plugin: Arc<ManagePluginHandler>,
|
||||
pub list_plugins: Arc<ListPluginsHandler>,
|
||||
pub configure_pipeline: Arc<ConfigurePipelineHandler>,
|
||||
pub list_pipelines: Arc<ListPipelinesHandler>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
||||
Reference in New Issue
Block a user