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

@@ -6,6 +6,7 @@ pub struct Config {
pub nats_url: String,
pub jwt_secret: String,
pub cors_allowed_origins: Vec<String>,
pub max_upload_bytes: usize,
}
impl Config {
@@ -26,6 +27,10 @@ impl Config {
.split(',')
.map(|s| s.trim().to_string())
.collect(),
max_upload_bytes: std::env::var("MAX_UPLOAD_BYTES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(256 * 1024 * 1024),
}
}
}

View File

@@ -1,5 +1,6 @@
use anyhow::Result;
use axum::Router;
use axum::extract::DefaultBodyLimit;
use axum::http::HeaderValue;
use std::sync::Arc;
use tower_http::{
@@ -32,7 +33,8 @@ pub async fn build_app(config: &Config) -> Result<Router> {
);
let storage_path = std::env::var("STORAGE_PATH").unwrap_or_else(|_| "./data/media".to_string());
let file_storage: Arc<LocalFileStorage> = Arc::new(LocalFileStorage::new(&storage_path));
let file_storage: Arc<dyn domain::ports::FileStoragePort> =
Arc::new(LocalFileStorage::new(&storage_path));
// Build per-context services
let identity = services::identity::build(&pool, &config.jwt_secret);
@@ -68,6 +70,7 @@ pub async fn build_app(config: &Config) -> Result<Router> {
Ok(app_router(&state)
.with_state(state)
.layer(DefaultBodyLimit::max(config.max_upload_bytes))
.layer(TraceLayer::new_for_http())
.layer(cors))
}

View File

@@ -5,10 +5,12 @@ use adapters_postgres::{
PostgresDerivativeRepository, PostgresDuplicateRepository, PostgresIngestTransaction,
PostgresSidecarRepository,
};
use adapters_storage::LocalFileStorage;
use adapters_storage::LocalVolumeFileResolver;
use domain::ports::FileStoragePort;
use application::catalog::{
CreateStackHandler, DeleteAssetHandler, DeleteStackHandler, DetectLivePhotosHandler,
GetAssetHandler, GetStackHandler, GetTimelineHandler, ListDuplicatesHandler,
GetAssetHandler, GetDateSummaryHandler, GetStackHandler, GetTimelineHandler,
ListDuplicatesHandler,
ReadAssetFileHandler, ReadDerivativeHandler, RegisterAssetHandler, ResolveDuplicateHandler,
SearchAssetsHandler, UpdateMetadataHandler,
};
@@ -21,7 +23,7 @@ use super::storage::StorageRepos;
pub fn build(
pool: &PgPool,
storage_repos: &StorageRepos,
file_storage: Arc<LocalFileStorage>,
file_storage: Arc<dyn FileStoragePort>,
event_publisher: Arc<dyn EventPublisher>,
) -> CatalogHandlers {
let asset_repo = Arc::new(PostgresAssetRepository::new(pool.clone()));
@@ -49,15 +51,20 @@ pub fn build(
metadata_repo.clone(),
));
let get_date_summary = Arc::new(GetDateSummaryHandler::new(asset_repo.clone()));
let update_metadata = Arc::new(UpdateMetadataHandler::new(
asset_repo.clone(),
metadata_repo.clone(),
event_publisher.clone(),
));
let volume_resolver = Arc::new(LocalVolumeFileResolver::new(
storage_repos.volume_repo.clone(),
));
let read_asset_file = Arc::new(ReadAssetFileHandler::new(
asset_repo.clone(),
file_storage.clone(),
volume_resolver,
));
let read_derivative = Arc::new(ReadDerivativeHandler::new(
@@ -103,6 +110,7 @@ pub fn build(
ingest_asset,
get_asset,
get_timeline,
get_date_summary,
update_metadata,
read_asset_file,
read_derivative,

View File

@@ -26,9 +26,10 @@ pub fn build(pool: &PgPool, jwt_secret: &str) -> IdentityServices {
issuer.clone(),
refresh_repo.clone(),
));
let get_profile = Arc::new(GetProfileHandler::new(user_repo));
let get_profile = Arc::new(GetProfileHandler::new(user_repo.clone()));
let refresh = Arc::new(RefreshTokenHandler::new(
refresh_repo.clone(),
user_repo,
issuer.clone(),
));
let logout = Arc::new(LogoutHandler::new(refresh_repo.clone()));

View File

@@ -6,7 +6,8 @@ use adapters_postgres::{
};
use application::processing::{
CompleteJobHandler, ConfigurePipelineHandler, EnqueueJobHandler, FailJobHandler,
ListJobsHandler, ManagePluginHandler, ReportBatchProgressHandler, StartJobHandler,
ListJobsHandler, ListPipelinesHandler, ListPluginsHandler, ManagePluginHandler,
ReportBatchProgressHandler, StartJobHandler,
};
use domain::ports::EventPublisher;
use presentation::state::ProcessingHandlers;
@@ -35,6 +36,8 @@ pub fn build(pool: &PgPool, event_publisher: Arc<dyn EventPublisher>) -> Process
let list_jobs = Arc::new(ListJobsHandler::new(job_repo.clone()));
let batch_progress = Arc::new(ReportBatchProgressHandler::new(batch_repo, job_repo));
let manage_plugin = Arc::new(ManagePluginHandler::new(plugin_repo.clone()));
let list_plugins = Arc::new(ListPluginsHandler::new(plugin_repo.clone()));
let list_pipelines = Arc::new(ListPipelinesHandler::new(pipeline_repo.clone()));
let configure_pipeline = Arc::new(ConfigurePipelineHandler::new(pipeline_repo, plugin_repo));
ProcessingHandlers {
@@ -45,6 +48,8 @@ pub fn build(pool: &PgPool, event_publisher: Arc<dyn EventPublisher>) -> Process
list_jobs,
batch_progress,
manage_plugin,
list_plugins,
configure_pipeline,
list_pipelines,
}
}

View File

@@ -4,12 +4,17 @@ use adapters_postgres::{
PgPool, PostgresLibraryPathRepository, PostgresQuotaRepository,
PostgresStorageVolumeRepository, PostgresUsageLedgerRepository,
};
use application::storage::{CheckQuotaHandler, RegisterLibraryPathHandler, RegisterVolumeHandler};
use application::storage::{
CheckQuotaHandler, DeleteLibraryPathHandler, DeleteVolumeHandler, ListAllLibraryPathsHandler,
ListIngestPathsHandler, ListVolumesHandler, RegisterLibraryPathHandler,
RegisterVolumeHandler,
};
use presentation::state::StorageHandlers;
/// Shared storage repos needed by other bounded contexts (catalog ingest, etc.).
pub struct StorageRepos {
pub path_repo: Arc<PostgresLibraryPathRepository>,
pub path_repo: Arc<dyn domain::ports::LibraryPathRepository>,
pub volume_repo: Arc<dyn domain::ports::StorageVolumeRepository>,
}
pub fn build(pool: &PgPool) -> (StorageRepos, StorageHandlers) {
@@ -18,20 +23,33 @@ pub fn build(pool: &PgPool) -> (StorageRepos, StorageHandlers) {
let quota_repo = Arc::new(PostgresQuotaRepository::new(pool.clone()));
let ledger_repo = Arc::new(PostgresUsageLedgerRepository::new(pool.clone()));
let list_volumes = Arc::new(ListVolumesHandler::new(volume_repo.clone()));
let register_volume = Arc::new(RegisterVolumeHandler::new(volume_repo.clone()));
let delete_volume = Arc::new(DeleteVolumeHandler::new(volume_repo.clone()));
let register_library_path = Arc::new(RegisterLibraryPathHandler::new(
volume_repo,
volume_repo.clone(),
path_repo.clone(),
));
let list_ingest_paths = Arc::new(ListIngestPathsHandler::new(path_repo.clone()));
let list_all_library_paths = Arc::new(ListAllLibraryPathsHandler::new(path_repo.clone()));
let delete_library_path = Arc::new(DeleteLibraryPathHandler::new(path_repo.clone()));
let check_quota = Arc::new(CheckQuotaHandler::new(quota_repo, ledger_repo));
let handlers = StorageHandlers {
register_volume,
delete_volume,
list_volumes,
register_library_path,
list_ingest_paths,
list_all_library_paths,
delete_library_path,
check_quota,
};
let repos = StorageRepos { path_repo };
let repos = StorageRepos {
path_repo,
volume_repo,
};
(repos, handlers)
}