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)
|
||||
|
||||
Reference in New Issue
Block a user