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:
@@ -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};
|
||||
|
||||
32
crates/application/src/catalog/queries/get_date_summary.rs
Normal file
32
crates/application/src/catalog/queries/get_date_summary.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
18
crates/application/src/processing/queries/list_pipelines.rs
Normal file
18
crates/application/src/processing/queries/list_pipelines.rs
Normal 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
|
||||
}
|
||||
}
|
||||
16
crates/application/src/processing/queries/list_plugins.rs
Normal file
16
crates/application/src/processing/queries/list_plugins.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
pub mod list_jobs;
|
||||
pub mod list_pipelines;
|
||||
pub mod list_plugins;
|
||||
pub mod report_batch_progress;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
16
crates/application/src/storage/commands/delete_volume.rs
Normal file
16
crates/application/src/storage/commands/delete_volume.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
28
crates/application/src/storage/queries/list_ingest_paths.rs
Normal file
28
crates/application/src/storage/queries/list_ingest_paths.rs
Normal 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
|
||||
}
|
||||
}
|
||||
16
crates/application/src/storage/queries/list_volumes.rs
Normal file
16
crates/application/src/storage/queries/list_volumes.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -1 +1,4 @@
|
||||
pub mod check_quota;
|
||||
pub mod list_all_library_paths;
|
||||
pub mod list_ingest_paths;
|
||||
pub mod list_volumes;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user