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

@@ -3,6 +3,7 @@ use crate::plugins::{
DirectoryScannerPlugin, MetadataExtractorPlugin, NoOpPlugin, SidecarSyncPlugin,
ThumbnailGeneratorPlugin,
};
use adapters_storage::LocalVolumeFileResolver;
use application::catalog::RegisterAssetHandler;
use domain::ports::{
EventPublisher, MetadataExtractorPort, SidecarWriterPort, ThumbnailGeneratorPort,
@@ -21,15 +22,18 @@ pub fn build_plugin_registry(
) -> InMemoryPluginRegistry {
let mut registry = InMemoryPluginRegistry::new();
let volume_resolver = Arc::new(LocalVolumeFileResolver::new(repos.volume.clone()));
registry.register(Arc::new(NoOpPlugin));
registry.register(Arc::new(MetadataExtractorPlugin::new(
repos.asset.clone(),
file_storage.clone(),
volume_resolver.clone(),
repos.metadata.clone(),
extractor,
)));
registry.register(Arc::new(ThumbnailGeneratorPlugin::new(
repos.asset.clone(),
volume_resolver,
file_storage.clone(),
repos.derivative.clone(),
thumbnail_gen,
@@ -43,7 +47,6 @@ pub fn build_plugin_registry(
registry.register(Arc::new(DirectoryScannerPlugin::new(
repos.volume.clone(),
repos.library_path.clone(),
file_storage.clone(),
register_handler,
)));

View File

@@ -13,6 +13,8 @@ mod sweep;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env().add_directive("worker=info".parse()?),

View File

@@ -3,17 +3,17 @@ use async_trait::async_trait;
use domain::{
catalog::entities::AssetType,
errors::DomainError,
ports::{FileStoragePort, LibraryPathRepository, PluginExecutor, StorageVolumeRepository},
ports::{LibraryPathRepository, PluginExecutor, StorageVolumeRepository},
value_objects::{MetadataValue, StructuredData, SystemId},
};
use sha2::{Digest, Sha256};
use std::path::PathBuf;
use std::sync::Arc;
use tracing::{info, warn};
pub struct DirectoryScannerPlugin {
volume_repo: Arc<dyn StorageVolumeRepository>,
path_repo: Arc<dyn LibraryPathRepository>,
file_storage: Arc<dyn FileStoragePort>,
register_handler: Arc<RegisterAssetHandler>,
}
@@ -21,13 +21,11 @@ impl DirectoryScannerPlugin {
pub fn new(
volume_repo: Arc<dyn StorageVolumeRepository>,
path_repo: Arc<dyn LibraryPathRepository>,
file_storage: Arc<dyn FileStoragePort>,
register_handler: Arc<RegisterAssetHandler>,
) -> Self {
Self {
volume_repo,
path_repo,
file_storage,
register_handler,
}
}
@@ -55,7 +53,7 @@ fn classify(filename: &str) -> Option<(AssetType, &'static str)> {
#[async_trait]
impl PluginExecutor for DirectoryScannerPlugin {
fn plugin_name(&self) -> &str {
"directory_scanner"
"scan_directory"
}
async fn execute(
@@ -92,8 +90,14 @@ impl PluginExecutor for DirectoryScannerPlugin {
DomainError::Validation(format!("LibraryPath {} has no designated owner", path_id))
})?;
let volume_base = volume
.uri_prefix
.strip_prefix("file://")
.unwrap_or(&volume.uri_prefix);
let volume_root = PathBuf::from(volume_base);
let scan_root = &library_path.relative_path;
info!(path = scan_root, volume = %volume.volume_name, "scanning directory");
info!(path = scan_root, volume = %volume.volume_name, base = %volume_root.display(), "scanning directory");
let mut found = 0u64;
let mut registered = 0u64;
@@ -101,29 +105,40 @@ impl PluginExecutor for DirectoryScannerPlugin {
let mut dirs_to_scan = vec![scan_root.to_string()];
while let Some(dir) = dirs_to_scan.pop() {
let entries = match self.file_storage.list_directory(&dir).await {
Ok(e) => e,
let abs_dir = if dir.is_empty() {
volume_root.clone()
} else {
volume_root.join(&dir)
};
let mut read_dir = match tokio::fs::read_dir(&abs_dir).await {
Ok(r) => r,
Err(e) => {
warn!(dir = dir, error = %e, "failed to list directory, skipping");
warn!(dir = %abs_dir.display(), error = %e, "failed to list directory, skipping");
continue;
}
};
for entry in entries {
let full_path = if dir.is_empty() {
entry.path.clone()
while let Ok(Some(entry)) = read_dir.next_entry().await {
let meta = match entry.metadata().await {
Ok(m) => m,
Err(_) => continue,
};
let name = entry.file_name().to_string_lossy().to_string();
let relative = if dir.is_empty() {
name.clone()
} else {
format!("{}/{}", dir, entry.path)
format!("{}/{}", dir, name)
};
if entry.is_directory {
dirs_to_scan.push(full_path);
if meta.is_dir() {
dirs_to_scan.push(relative);
continue;
}
found += 1;
let (asset_type, mime_type) = match classify(&entry.path) {
let (asset_type, mime_type) = match classify(&name) {
Some(c) => c,
None => {
skipped += 1;
@@ -131,10 +146,11 @@ impl PluginExecutor for DirectoryScannerPlugin {
}
};
let data = match self.file_storage.read_file(&full_path).await {
let abs_path = volume_root.join(&relative);
let data = match tokio::fs::read(&abs_path).await {
Ok(d) => d,
Err(e) => {
warn!(path = full_path, error = %e, "failed to read file, skipping");
warn!(path = %abs_path.display(), error = %e, "failed to read file, skipping");
skipped += 1;
continue;
}
@@ -144,7 +160,7 @@ impl PluginExecutor for DirectoryScannerPlugin {
let cmd = RegisterAssetCommand {
volume_id: library_path.volume_id,
relative_path: full_path.clone(),
relative_path: relative.clone(),
checksum,
asset_type,
mime_type: mime_type.to_string(),
@@ -156,11 +172,11 @@ impl PluginExecutor for DirectoryScannerPlugin {
Ok((asset, dup)) => {
registered += 1;
if dup.is_some() {
info!(path = full_path, asset_id = %asset.asset_id, "registered (duplicate detected)");
info!(path = relative, asset_id = %asset.asset_id, "registered (duplicate detected)");
}
}
Err(e) => {
warn!(path = full_path, error = %e, "failed to register asset");
warn!(path = relative, error = %e, "failed to register asset");
skipped += 1;
}
}

View File

@@ -3,8 +3,8 @@ use domain::{
entities::{AssetMetadata, MetadataSource},
errors::DomainError,
ports::{
AssetMetadataRepository, AssetRepository, FileStoragePort, MetadataExtractorPort,
PluginExecutor,
AssetMetadataRepository, AssetRepository, MetadataExtractorPort, PluginExecutor,
VolumeFileResolver,
},
value_objects::{MetadataValue, StructuredData, SystemId},
};
@@ -13,7 +13,7 @@ use tracing::info;
pub struct MetadataExtractorPlugin {
asset_repo: Arc<dyn AssetRepository>,
file_storage: Arc<dyn FileStoragePort>,
volume_resolver: Arc<dyn VolumeFileResolver>,
metadata_repo: Arc<dyn AssetMetadataRepository>,
extractor: Arc<dyn MetadataExtractorPort>,
}
@@ -21,13 +21,13 @@ pub struct MetadataExtractorPlugin {
impl MetadataExtractorPlugin {
pub fn new(
asset_repo: Arc<dyn AssetRepository>,
file_storage: Arc<dyn FileStoragePort>,
volume_resolver: Arc<dyn VolumeFileResolver>,
metadata_repo: Arc<dyn AssetMetadataRepository>,
extractor: Arc<dyn MetadataExtractorPort>,
) -> Self {
Self {
asset_repo,
file_storage,
volume_resolver,
metadata_repo,
extractor,
}
@@ -56,8 +56,13 @@ impl PluginExecutor for MetadataExtractorPlugin {
.await?
.ok_or_else(|| DomainError::NotFound(format!("Asset {} not found", asset_id)))?;
let path = &asset.source_reference.relative_path;
let data = self.file_storage.read_file(path).await?;
let data = self
.volume_resolver
.read_by_volume(
&asset.source_reference.volume_id,
&asset.source_reference.relative_path,
)
.await?;
let mut extracted = self.extractor.extract(&data)?;
extracted.insert("file_size_bytes", MetadataValue::Integer(data.len() as i64));

View File

@@ -4,7 +4,7 @@ use domain::{
errors::DomainError,
ports::{
AssetRepository, DerivativeRepository, FileStoragePort, PluginExecutor,
ThumbnailGeneratorPort,
ThumbnailGeneratorPort, VolumeFileResolver,
},
value_objects::{MetadataValue, StructuredData, SystemId},
};
@@ -13,6 +13,7 @@ use tracing::info;
pub struct ThumbnailGeneratorPlugin {
asset_repo: Arc<dyn AssetRepository>,
volume_resolver: Arc<dyn VolumeFileResolver>,
file_storage: Arc<dyn FileStoragePort>,
derivative_repo: Arc<dyn DerivativeRepository>,
thumbnail_gen: Arc<dyn ThumbnailGeneratorPort>,
@@ -21,12 +22,14 @@ pub struct ThumbnailGeneratorPlugin {
impl ThumbnailGeneratorPlugin {
pub fn new(
asset_repo: Arc<dyn AssetRepository>,
volume_resolver: Arc<dyn VolumeFileResolver>,
file_storage: Arc<dyn FileStoragePort>,
derivative_repo: Arc<dyn DerivativeRepository>,
thumbnail_gen: Arc<dyn ThumbnailGeneratorPort>,
) -> Self {
Self {
asset_repo,
volume_resolver,
file_storage,
derivative_repo,
thumbnail_gen,
@@ -92,8 +95,11 @@ impl PluginExecutor for ThumbnailGeneratorPlugin {
}
let source_bytes = self
.file_storage
.read_file(&asset.source_reference.relative_path)
.volume_resolver
.read_by_volume(
&asset.source_reference.volume_id,
&asset.source_reference.relative_path,
)
.await?;
let output = self