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:
@@ -1,7 +1,9 @@
|
||||
pub mod adapter;
|
||||
pub mod config;
|
||||
pub mod local_file_storage;
|
||||
pub mod volume_resolver;
|
||||
|
||||
pub use adapter::ObjectStorageAdapter;
|
||||
pub use config::{StorageConfig, build_store};
|
||||
pub use local_file_storage::LocalFileStorage;
|
||||
pub use volume_resolver::LocalVolumeFileResolver;
|
||||
|
||||
91
crates/adapters/storage/src/volume_resolver.rs
Normal file
91
crates/adapters/storage/src/volume_resolver.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::{DataStream, StorageVolumeRepository, VolumeFileResolver},
|
||||
value_objects::SystemId,
|
||||
};
|
||||
use futures::StreamExt;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
pub struct LocalVolumeFileResolver {
|
||||
volume_repo: Arc<dyn StorageVolumeRepository>,
|
||||
}
|
||||
|
||||
impl LocalVolumeFileResolver {
|
||||
pub fn new(volume_repo: Arc<dyn StorageVolumeRepository>) -> Self {
|
||||
Self { volume_repo }
|
||||
}
|
||||
|
||||
async fn resolve_path(
|
||||
&self,
|
||||
volume_id: &SystemId,
|
||||
relative_path: &str,
|
||||
) -> Result<PathBuf, DomainError> {
|
||||
let volume = self
|
||||
.volume_repo
|
||||
.find_by_id(volume_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("Volume {} not found", volume_id)))?;
|
||||
|
||||
let base = volume
|
||||
.uri_prefix
|
||||
.strip_prefix("file://")
|
||||
.unwrap_or(&volume.uri_prefix);
|
||||
|
||||
let full = if relative_path.is_empty() {
|
||||
PathBuf::from(base)
|
||||
} else {
|
||||
PathBuf::from(base).join(relative_path)
|
||||
};
|
||||
|
||||
Ok(full)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl VolumeFileResolver for LocalVolumeFileResolver {
|
||||
async fn open_by_volume(
|
||||
&self,
|
||||
volume_id: &SystemId,
|
||||
relative_path: &str,
|
||||
) -> Result<(DataStream, u64), DomainError> {
|
||||
let full = self.resolve_path(volume_id, relative_path).await?;
|
||||
let meta = tokio::fs::metadata(&full)
|
||||
.await
|
||||
.map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => {
|
||||
DomainError::NotFound(full.display().to_string())
|
||||
}
|
||||
_ => DomainError::Internal(format!("Failed to stat file: {e}")),
|
||||
})?;
|
||||
let file = tokio::fs::File::open(&full)
|
||||
.await
|
||||
.map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => {
|
||||
DomainError::NotFound(full.display().to_string())
|
||||
}
|
||||
_ => DomainError::Internal(format!("Failed to open file: {e}")),
|
||||
})?;
|
||||
let stream = ReaderStream::new(file)
|
||||
.map(|r| r.map_err(|e| DomainError::Internal(format!("Read error: {e}"))));
|
||||
Ok((Box::pin(stream), meta.len()))
|
||||
}
|
||||
|
||||
async fn read_by_volume(
|
||||
&self,
|
||||
volume_id: &SystemId,
|
||||
relative_path: &str,
|
||||
) -> Result<Bytes, DomainError> {
|
||||
let full = self.resolve_path(volume_id, relative_path).await?;
|
||||
let data = tokio::fs::read(&full).await.map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => {
|
||||
DomainError::NotFound(full.display().to_string())
|
||||
}
|
||||
_ => DomainError::Internal(format!("Failed to read file: {e}")),
|
||||
})?;
|
||||
Ok(Bytes::from(data))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user