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

@@ -13,6 +13,7 @@ pub use commands::resolve_duplicate::{
};
pub use commands::update_metadata::{UpdateMetadataCommand, UpdateMetadataHandler};
pub use queries::get_asset::{GetAssetHandler, GetAssetQuery};
pub use queries::get_date_summary::{DateSummaryEntry, GetDateSummaryHandler, GetDateSummaryQuery};
pub use queries::get_stack::{GetStackHandler, GetStackQuery};
pub use queries::get_timeline::{GetTimelineHandler, GetTimelineQuery, TimelineResult};
pub use queries::list_stacks::{ListStacksHandler, ListStacksQuery};

View File

@@ -0,0 +1,32 @@
use domain::{errors::DomainError, ports::AssetRepository, value_objects::SystemId};
use std::sync::Arc;
pub struct GetDateSummaryQuery {
pub owner_id: SystemId,
}
pub struct DateSummaryEntry {
pub date: chrono::NaiveDate,
pub count: u64,
}
pub struct GetDateSummaryHandler {
asset_repo: Arc<dyn AssetRepository>,
}
impl GetDateSummaryHandler {
pub fn new(asset_repo: Arc<dyn AssetRepository>) -> Self {
Self { asset_repo }
}
pub async fn execute(
&self,
query: GetDateSummaryQuery,
) -> Result<Vec<DateSummaryEntry>, DomainError> {
let rows = self.asset_repo.date_summary(&query.owner_id).await?;
Ok(rows
.into_iter()
.map(|(date, count)| DateSummaryEntry { date, count })
.collect())
}
}

View File

@@ -1,4 +1,5 @@
pub mod get_asset;
pub mod get_date_summary;
pub mod get_stack;
pub mod get_timeline;
pub mod list_stacks;

View File

@@ -1,6 +1,6 @@
use domain::{
errors::DomainError,
ports::{AssetRepository, DataStream, FileStoragePort},
ports::{AssetRepository, DataStream, VolumeFileResolver},
value_objects::SystemId,
};
use std::sync::Arc;
@@ -20,17 +20,17 @@ pub struct AssetFileResult {
pub struct ReadAssetFileHandler {
asset_repo: Arc<dyn AssetRepository>,
file_storage: Arc<dyn FileStoragePort>,
volume_resolver: Arc<dyn VolumeFileResolver>,
}
impl ReadAssetFileHandler {
pub fn new(
asset_repo: Arc<dyn AssetRepository>,
file_storage: Arc<dyn FileStoragePort>,
volume_resolver: Arc<dyn VolumeFileResolver>,
) -> Self {
Self {
asset_repo,
file_storage,
volume_resolver,
}
}
@@ -46,8 +46,11 @@ impl ReadAssetFileHandler {
}
let (stream, size) = self
.file_storage
.open_file(&asset.source_reference.relative_path)
.volume_resolver
.open_by_volume(
&asset.source_reference.volume_id,
&asset.source_reference.relative_path,
)
.await?;
let filename = asset

View File

@@ -134,6 +134,13 @@ impl AssetRepository for VisibilityFilteredAssetRepository {
self.inner.count_search(owner_id, filters).await
}
async fn date_summary(
&self,
owner_id: &SystemId,
) -> Result<Vec<(chrono::NaiveDate, u64)>, DomainError> {
self.inner.date_summary(owner_id).await
}
async fn save(&self, asset: &Asset) -> Result<(), DomainError> {
self.inner.save(asset).await
}

View File

@@ -52,7 +52,7 @@ impl LoginUserHandler {
if !valid {
return Err(DomainError::Unauthorized("Invalid credentials".to_string()));
}
let access_token = self.issuer.issue(&user.id, "user").await?;
let access_token = self.issuer.issue(&user.id, &user.role).await?;
let (raw_refresh, _) = generate_refresh_token(&self.refresh_repo, &user.id).await?;
Ok((user, access_token, raw_refresh))
}

View File

@@ -1,7 +1,7 @@
use super::login_user::generate_refresh_token;
use domain::{
errors::DomainError,
ports::{RefreshTokenRepository, TokenIssuer},
ports::{RefreshTokenRepository, TokenIssuer, UserRepository},
};
use sha2::{Digest, Sha256};
use std::sync::Arc;
@@ -13,16 +13,19 @@ pub struct RefreshTokenCommand {
pub struct RefreshTokenHandler {
refresh_repo: Arc<dyn RefreshTokenRepository>,
user_repo: Arc<dyn UserRepository>,
issuer: Arc<dyn TokenIssuer>,
}
impl RefreshTokenHandler {
pub fn new(
refresh_repo: Arc<dyn RefreshTokenRepository>,
user_repo: Arc<dyn UserRepository>,
issuer: Arc<dyn TokenIssuer>,
) -> Self {
Self {
refresh_repo,
user_repo,
issuer,
}
}
@@ -42,11 +45,16 @@ impl RefreshTokenHandler {
));
}
// Rotation: delete old, issue new pair
let user = self
.user_repo
.find_by_id(&token.user_id)
.await?
.ok_or_else(|| DomainError::NotFound("User not found".to_string()))?;
self.refresh_repo.delete(&token.token_id).await?;
let access_token = self.issuer.issue(&token.user_id, "user").await?;
let (raw_refresh, _) = generate_refresh_token(&self.refresh_repo, &token.user_id).await?;
let access_token = self.issuer.issue(&user.id, &user.role).await?;
let (raw_refresh, _) = generate_refresh_token(&self.refresh_repo, &user.id).await?;
Ok((access_token, raw_refresh))
}

View File

@@ -53,7 +53,11 @@ impl RegisterUserHandler {
)));
}
let hash = self.hasher.hash(&cmd.password).await?;
let user = User::new(&cmd.username, email, hash);
let is_first = self.user_repo.count().await? == 0;
let mut user = User::new(&cmd.username, email, hash);
if is_first {
user.role = "admin".to_string();
}
self.user_repo.save(&user).await?;
Ok(user)
}

View File

@@ -12,6 +12,8 @@ pub use commands::manage_plugin::{ManagePluginCommand, ManagePluginHandler, Plug
pub use commands::process_next_job::{ProcessNextJobCommand, ProcessNextJobHandler};
pub use commands::start_job::{StartJobCommand, StartJobHandler};
pub use queries::list_jobs::{JobListResult, ListJobsHandler, ListJobsQuery};
pub use queries::list_pipelines::ListPipelinesHandler;
pub use queries::list_plugins::ListPluginsHandler;
pub use queries::report_batch_progress::{
BatchProgress, ReportBatchProgressHandler, ReportBatchProgressQuery,
};

View File

@@ -0,0 +1,18 @@
use domain::{
entities::ProcessingPipeline, errors::DomainError, ports::PipelineRepository,
};
use std::sync::Arc;
pub struct ListPipelinesHandler {
repo: Arc<dyn PipelineRepository>,
}
impl ListPipelinesHandler {
pub fn new(repo: Arc<dyn PipelineRepository>) -> Self {
Self { repo }
}
pub async fn execute(&self) -> Result<Vec<ProcessingPipeline>, DomainError> {
self.repo.find_all().await
}
}

View File

@@ -0,0 +1,16 @@
use domain::{entities::Plugin, errors::DomainError, ports::PluginRepository};
use std::sync::Arc;
pub struct ListPluginsHandler {
repo: Arc<dyn PluginRepository>,
}
impl ListPluginsHandler {
pub fn new(repo: Arc<dyn PluginRepository>) -> Self {
Self { repo }
}
pub async fn execute(&self) -> Result<Vec<Plugin>, DomainError> {
self.repo.find_all().await
}
}

View File

@@ -1,2 +1,4 @@
pub mod list_jobs;
pub mod list_pipelines;
pub mod list_plugins;
pub mod report_batch_progress;

View File

@@ -0,0 +1,16 @@
use domain::{errors::DomainError, ports::LibraryPathRepository, value_objects::SystemId};
use std::sync::Arc;
pub struct DeleteLibraryPathHandler {
repo: Arc<dyn LibraryPathRepository>,
}
impl DeleteLibraryPathHandler {
pub fn new(repo: Arc<dyn LibraryPathRepository>) -> Self {
Self { repo }
}
pub async fn execute(&self, id: SystemId) -> Result<(), DomainError> {
self.repo.delete(&id).await
}
}

View File

@@ -0,0 +1,16 @@
use domain::{errors::DomainError, ports::StorageVolumeRepository, value_objects::SystemId};
use std::sync::Arc;
pub struct DeleteVolumeHandler {
repo: Arc<dyn StorageVolumeRepository>,
}
impl DeleteVolumeHandler {
pub fn new(repo: Arc<dyn StorageVolumeRepository>) -> Self {
Self { repo }
}
pub async fn execute(&self, id: SystemId) -> Result<(), DomainError> {
self.repo.delete(&id).await
}
}

View File

@@ -1,3 +1,5 @@
pub mod delete_library_path;
pub mod delete_volume;
pub mod ingest_asset;
pub mod register_library_path;
pub mod register_volume;

View File

@@ -1,7 +1,12 @@
pub mod commands;
pub mod queries;
pub use commands::delete_library_path::DeleteLibraryPathHandler;
pub use commands::delete_volume::DeleteVolumeHandler;
pub use commands::ingest_asset::{IngestAssetCommand, IngestAssetHandler};
pub use commands::register_library_path::{RegisterLibraryPathCommand, RegisterLibraryPathHandler};
pub use commands::register_volume::{RegisterVolumeCommand, RegisterVolumeHandler};
pub use queries::check_quota::{CheckQuotaHandler, CheckQuotaQuery};
pub use queries::list_all_library_paths::ListAllLibraryPathsHandler;
pub use queries::list_ingest_paths::{ListIngestPathsHandler, ListIngestPathsQuery};
pub use queries::list_volumes::ListVolumesHandler;

View File

@@ -0,0 +1,16 @@
use domain::{entities::LibraryPath, errors::DomainError, ports::LibraryPathRepository};
use std::sync::Arc;
pub struct ListAllLibraryPathsHandler {
repo: Arc<dyn LibraryPathRepository>,
}
impl ListAllLibraryPathsHandler {
pub fn new(repo: Arc<dyn LibraryPathRepository>) -> Self {
Self { repo }
}
pub async fn execute(&self) -> Result<Vec<LibraryPath>, DomainError> {
self.repo.find_all().await
}
}

View File

@@ -0,0 +1,28 @@
use domain::{
entities::LibraryPath,
errors::DomainError,
ports::LibraryPathRepository,
value_objects::SystemId,
};
use std::sync::Arc;
pub struct ListIngestPathsQuery {
pub user_id: SystemId,
}
pub struct ListIngestPathsHandler {
repo: Arc<dyn LibraryPathRepository>,
}
impl ListIngestPathsHandler {
pub fn new(repo: Arc<dyn LibraryPathRepository>) -> Self {
Self { repo }
}
pub async fn execute(
&self,
query: ListIngestPathsQuery,
) -> Result<Vec<LibraryPath>, DomainError> {
self.repo.find_ingest_destinations(&query.user_id).await
}
}

View File

@@ -0,0 +1,16 @@
use domain::{entities::StorageVolume, errors::DomainError, ports::StorageVolumeRepository};
use std::sync::Arc;
pub struct ListVolumesHandler {
repo: Arc<dyn StorageVolumeRepository>,
}
impl ListVolumesHandler {
pub fn new(repo: Arc<dyn StorageVolumeRepository>) -> Self {
Self { repo }
}
pub async fn execute(&self) -> Result<Vec<StorageVolume>, DomainError> {
self.repo.find_all().await
}
}

View File

@@ -1 +1,4 @@
pub mod check_quota;
pub mod list_all_library_paths;
pub mod list_ingest_paths;
pub mod list_volumes;

View File

@@ -103,6 +103,10 @@ impl UserRepository for InMemoryUserRepository {
self.users.lock().await.remove(&id.to_string());
Ok(())
}
async fn count(&self) -> Result<u64, DomainError> {
Ok(self.users.lock().await.len() as u64)
}
}
in_memory_repo!(InMemoryAssetRepository, Asset);
@@ -173,6 +177,21 @@ impl AssetRepository for InMemoryAssetRepository {
self.count_by_owner(owner_id).await
}
async fn date_summary(
&self,
owner_id: &SystemId,
) -> Result<Vec<(chrono::NaiveDate, u64)>, DomainError> {
let data = self.data.lock().await;
let mut map = std::collections::BTreeMap::<chrono::NaiveDate, u64>::new();
for asset in data.values() {
if &asset.owner_user_id == owner_id {
let date = asset.created_at.as_datetime().date_naive();
*map.entry(date).or_default() += 1;
}
}
Ok(map.into_iter().rev().collect())
}
async fn save(&self, asset: &Asset) -> Result<(), DomainError> {
self.data
.lock()
@@ -385,6 +404,10 @@ impl LibraryPathRepository for InMemoryLibraryPathRepository {
Ok(self.data.lock().await.get(&id.to_string()).cloned())
}
async fn find_all(&self) -> Result<Vec<LibraryPath>, DomainError> {
Ok(self.data.lock().await.values().cloned().collect())
}
async fn find_by_volume(&self, volume_id: &SystemId) -> Result<Vec<LibraryPath>, DomainError> {
Ok(self
.data
@@ -918,6 +941,10 @@ impl PluginRepository for InMemoryPluginRepository {
Ok(self.data.lock().await.get(&id.to_string()).cloned())
}
async fn find_all(&self) -> Result<Vec<Plugin>, DomainError> {
Ok(self.data.lock().await.values().cloned().collect())
}
async fn find_enabled(&self) -> Result<Vec<Plugin>, DomainError> {
Ok(self
.data
@@ -946,6 +973,10 @@ impl PipelineRepository for InMemoryPipelineRepository {
Ok(self.data.lock().await.get(&id.to_string()).cloned())
}
async fn find_all(&self) -> Result<Vec<ProcessingPipeline>, DomainError> {
Ok(self.data.lock().await.values().cloned().collect())
}
async fn find_by_trigger(&self, event: &str) -> Result<Vec<ProcessingPipeline>, DomainError> {
Ok(self
.data