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
crates/adapters/postgres/migrations/016_user_roles.sql
Normal file
1
crates/adapters/postgres/migrations/016_user_roles.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user';
|
||||
@@ -0,0 +1 @@
|
||||
UPDATE plugins SET name = 'scan_directory' WHERE plugin_id = 'a0000000-0000-4000-8000-000000000005';
|
||||
@@ -189,10 +189,16 @@ impl AssetRepository for PostgresAssetRepository {
|
||||
offset: u32,
|
||||
) -> Result<Vec<Asset>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, AssetRow>(
|
||||
"SELECT asset_id, volume_id, relative_path, checksum, asset_type, mime_type,
|
||||
file_size, is_processed, owner_user_id, created_at
|
||||
FROM assets WHERE owner_user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
"SELECT a.asset_id, a.volume_id, a.relative_path, a.checksum, a.asset_type, a.mime_type,
|
||||
a.file_size, a.is_processed, a.owner_user_id, a.created_at
|
||||
FROM assets a
|
||||
LEFT JOIN asset_metadata am
|
||||
ON am.asset_id = a.asset_id AND am.metadata_source = 'exif_extracted'
|
||||
WHERE a.owner_user_id = $1
|
||||
ORDER BY COALESCE(
|
||||
(am.data->>'DateTimeOriginal')::timestamptz,
|
||||
a.created_at
|
||||
) DESC
|
||||
LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(*owner_id.as_uuid())
|
||||
@@ -296,6 +302,30 @@ impl AssetRepository for PostgresAssetRepository {
|
||||
Ok(count as u64)
|
||||
}
|
||||
|
||||
async fn date_summary(
|
||||
&self,
|
||||
owner_id: &SystemId,
|
||||
) -> Result<Vec<(chrono::NaiveDate, u64)>, DomainError> {
|
||||
let rows: Vec<(chrono::NaiveDate, i64)> = sqlx::query_as(
|
||||
"SELECT COALESCE(
|
||||
(am.data->>'DateTimeOriginal')::timestamptz,
|
||||
a.created_at
|
||||
)::date AS day,
|
||||
COUNT(*) AS cnt
|
||||
FROM assets a
|
||||
LEFT JOIN asset_metadata am
|
||||
ON am.asset_id = a.asset_id AND am.metadata_source = 'exif_extracted'
|
||||
WHERE a.owner_user_id = $1
|
||||
GROUP BY day ORDER BY day DESC",
|
||||
)
|
||||
.bind(*owner_id.as_uuid())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_pg()?;
|
||||
|
||||
Ok(rows.into_iter().map(|(d, c)| (d, c as u64)).collect())
|
||||
}
|
||||
|
||||
async fn save(&self, asset: &Asset) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO assets (asset_id, volume_id, relative_path, checksum, asset_type,
|
||||
|
||||
@@ -15,6 +15,7 @@ struct UserRow {
|
||||
username: String,
|
||||
email: String,
|
||||
password_hash: String,
|
||||
role: String,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -26,6 +27,7 @@ impl TryFrom<UserRow> for domain::entities::User {
|
||||
username: r.username,
|
||||
email: Email::new(r.email)?,
|
||||
password_hash: PasswordHash::from_hash(r.password_hash),
|
||||
role: r.role,
|
||||
created_at: r.created_at,
|
||||
})
|
||||
}
|
||||
@@ -40,7 +42,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
id: &SystemId,
|
||||
) -> Result<Option<domain::entities::User>, DomainError> {
|
||||
let row = sqlx::query_as::<_, UserRow>(
|
||||
"SELECT id, username, email, password_hash, created_at FROM users WHERE id = $1",
|
||||
"SELECT id, username, email, password_hash, role, created_at FROM users WHERE id = $1",
|
||||
)
|
||||
.bind(*id.as_uuid())
|
||||
.fetch_optional(&self.pool)
|
||||
@@ -55,7 +57,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
email: &Email,
|
||||
) -> Result<Option<domain::entities::User>, DomainError> {
|
||||
let row = sqlx::query_as::<_, UserRow>(
|
||||
"SELECT id, username, email, password_hash, created_at FROM users WHERE email = $1",
|
||||
"SELECT id, username, email, password_hash, role, created_at FROM users WHERE email = $1",
|
||||
)
|
||||
.bind(email.as_str())
|
||||
.fetch_optional(&self.pool)
|
||||
@@ -70,7 +72,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
username: &str,
|
||||
) -> Result<Option<domain::entities::User>, DomainError> {
|
||||
let row = sqlx::query_as::<_, UserRow>(
|
||||
"SELECT id, username, email, password_hash, created_at FROM users WHERE username = $1",
|
||||
"SELECT id, username, email, password_hash, role, created_at FROM users WHERE username = $1",
|
||||
)
|
||||
.bind(username)
|
||||
.fetch_optional(&self.pool)
|
||||
@@ -82,18 +84,20 @@ impl UserRepository for PostgresUserRepository {
|
||||
|
||||
async fn save(&self, user: &domain::entities::User) -> Result<(), DomainError> {
|
||||
sqlx::query_as::<_, UserRow>(
|
||||
"INSERT INTO users (id, username, email, password_hash, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
"INSERT INTO users (id, username, email, password_hash, role, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
username = EXCLUDED.username,
|
||||
email = EXCLUDED.email,
|
||||
password_hash = EXCLUDED.password_hash
|
||||
RETURNING id, username, email, password_hash, created_at",
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
role = EXCLUDED.role
|
||||
RETURNING id, username, email, password_hash, role, created_at",
|
||||
)
|
||||
.bind(*user.id.as_uuid())
|
||||
.bind(&user.username)
|
||||
.bind(user.email.as_str())
|
||||
.bind(user.password_hash.as_str())
|
||||
.bind(&user.role)
|
||||
.bind(user.created_at)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
@@ -109,6 +113,14 @@ impl UserRepository for PostgresUserRepository {
|
||||
.map_pg()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn count(&self) -> Result<u64, DomainError> {
|
||||
let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_pg()?;
|
||||
Ok(count as u64)
|
||||
}
|
||||
}
|
||||
|
||||
// --- PostgresRefreshTokenRepository ---
|
||||
|
||||
@@ -405,6 +405,17 @@ impl PluginRepository for PostgresPluginRepository {
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn find_all(&self) -> Result<Vec<Plugin>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, PluginRow>(
|
||||
"SELECT plugin_id, name, plugin_type, is_enabled, configuration FROM plugins",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_pg()?;
|
||||
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn find_enabled(&self) -> Result<Vec<Plugin>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, PluginRow>(
|
||||
"SELECT plugin_id, name, plugin_type, is_enabled, configuration
|
||||
@@ -521,6 +532,17 @@ impl PipelineRepository for PostgresPipelineRepository {
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn find_all(&self) -> Result<Vec<ProcessingPipeline>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, PipelineRow>(
|
||||
"SELECT pipeline_id, trigger_event, steps FROM processing_pipelines",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_pg()?;
|
||||
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn find_by_trigger(&self, event: &str) -> Result<Vec<ProcessingPipeline>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, PipelineRow>(
|
||||
"SELECT pipeline_id, trigger_event, steps
|
||||
|
||||
@@ -160,6 +160,18 @@ impl LibraryPathRepository for PostgresLibraryPathRepository {
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn find_all(&self) -> Result<Vec<LibraryPath>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, LibraryPathRow>(
|
||||
"SELECT path_id, volume_id, relative_path, is_ingest_destination, ownership_policy, designated_owner_id
|
||||
FROM library_paths",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_pg()?;
|
||||
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn find_by_volume(&self, volume_id: &SystemId) -> Result<Vec<LibraryPath>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, LibraryPathRow>(
|
||||
"SELECT path_id, volume_id, relative_path, is_ingest_destination, ownership_policy, designated_owner_id
|
||||
@@ -180,7 +192,7 @@ impl LibraryPathRepository for PostgresLibraryPathRepository {
|
||||
let rows = sqlx::query_as::<_, LibraryPathRow>(
|
||||
"SELECT path_id, volume_id, relative_path, is_ingest_destination, ownership_policy, designated_owner_id
|
||||
FROM library_paths
|
||||
WHERE is_ingest_destination = true AND designated_owner_id = $1",
|
||||
WHERE is_ingest_destination = true AND (designated_owner_id = $1 OR designated_owner_id IS NULL)",
|
||||
)
|
||||
.bind(*owner_id.as_uuid())
|
||||
.fetch_all(&self.pool)
|
||||
|
||||
@@ -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