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:
2026-06-01 01:35:43 +02:00
parent 49f77a78b9
commit 957737ac9b
101 changed files with 4679 additions and 109 deletions

View File

@@ -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,

View File

@@ -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, _) =

View File

@@ -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,

View File

@@ -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;

View File

@@ -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),

View File

@@ -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))
}

View File

@@ -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))
}

View File

@@ -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)]