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:
@@ -34,6 +34,11 @@ CORS_ALLOWED_ORIGINS=http://localhost:8000,http://localhost:5173
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
STORAGE_PATH=./data/media
|
STORAGE_PATH=./data/media
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Uploads (default 256 MiB)
|
||||||
|
# ============================================================================
|
||||||
|
# MAX_UPLOAD_BYTES=268435456
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Logging
|
# Logging
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
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,
|
offset: u32,
|
||||||
) -> Result<Vec<Asset>, DomainError> {
|
) -> Result<Vec<Asset>, DomainError> {
|
||||||
let rows = sqlx::query_as::<_, AssetRow>(
|
let rows = sqlx::query_as::<_, AssetRow>(
|
||||||
"SELECT asset_id, volume_id, relative_path, checksum, asset_type, mime_type,
|
"SELECT a.asset_id, a.volume_id, a.relative_path, a.checksum, a.asset_type, a.mime_type,
|
||||||
file_size, is_processed, owner_user_id, created_at
|
a.file_size, a.is_processed, a.owner_user_id, a.created_at
|
||||||
FROM assets WHERE owner_user_id = $1
|
FROM assets a
|
||||||
ORDER BY created_at DESC
|
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",
|
LIMIT $2 OFFSET $3",
|
||||||
)
|
)
|
||||||
.bind(*owner_id.as_uuid())
|
.bind(*owner_id.as_uuid())
|
||||||
@@ -296,6 +302,30 @@ impl AssetRepository for PostgresAssetRepository {
|
|||||||
Ok(count as u64)
|
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> {
|
async fn save(&self, asset: &Asset) -> Result<(), DomainError> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO assets (asset_id, volume_id, relative_path, checksum, asset_type,
|
"INSERT INTO assets (asset_id, volume_id, relative_path, checksum, asset_type,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ struct UserRow {
|
|||||||
username: String,
|
username: String,
|
||||||
email: String,
|
email: String,
|
||||||
password_hash: String,
|
password_hash: String,
|
||||||
|
role: String,
|
||||||
created_at: DateTime<Utc>,
|
created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ impl TryFrom<UserRow> for domain::entities::User {
|
|||||||
username: r.username,
|
username: r.username,
|
||||||
email: Email::new(r.email)?,
|
email: Email::new(r.email)?,
|
||||||
password_hash: PasswordHash::from_hash(r.password_hash),
|
password_hash: PasswordHash::from_hash(r.password_hash),
|
||||||
|
role: r.role,
|
||||||
created_at: r.created_at,
|
created_at: r.created_at,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -40,7 +42,7 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
id: &SystemId,
|
id: &SystemId,
|
||||||
) -> Result<Option<domain::entities::User>, DomainError> {
|
) -> Result<Option<domain::entities::User>, DomainError> {
|
||||||
let row = sqlx::query_as::<_, UserRow>(
|
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())
|
.bind(*id.as_uuid())
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
@@ -55,7 +57,7 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
email: &Email,
|
email: &Email,
|
||||||
) -> Result<Option<domain::entities::User>, DomainError> {
|
) -> Result<Option<domain::entities::User>, DomainError> {
|
||||||
let row = sqlx::query_as::<_, UserRow>(
|
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())
|
.bind(email.as_str())
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
@@ -70,7 +72,7 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
username: &str,
|
username: &str,
|
||||||
) -> Result<Option<domain::entities::User>, DomainError> {
|
) -> Result<Option<domain::entities::User>, DomainError> {
|
||||||
let row = sqlx::query_as::<_, UserRow>(
|
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)
|
.bind(username)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
@@ -82,18 +84,20 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
|
|
||||||
async fn save(&self, user: &domain::entities::User) -> Result<(), DomainError> {
|
async fn save(&self, user: &domain::entities::User) -> Result<(), DomainError> {
|
||||||
sqlx::query_as::<_, UserRow>(
|
sqlx::query_as::<_, UserRow>(
|
||||||
"INSERT INTO users (id, username, email, password_hash, created_at)
|
"INSERT INTO users (id, username, email, password_hash, role, created_at)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
username = EXCLUDED.username,
|
username = EXCLUDED.username,
|
||||||
email = EXCLUDED.email,
|
email = EXCLUDED.email,
|
||||||
password_hash = EXCLUDED.password_hash
|
password_hash = EXCLUDED.password_hash,
|
||||||
RETURNING id, username, email, password_hash, created_at",
|
role = EXCLUDED.role
|
||||||
|
RETURNING id, username, email, password_hash, role, created_at",
|
||||||
)
|
)
|
||||||
.bind(*user.id.as_uuid())
|
.bind(*user.id.as_uuid())
|
||||||
.bind(&user.username)
|
.bind(&user.username)
|
||||||
.bind(user.email.as_str())
|
.bind(user.email.as_str())
|
||||||
.bind(user.password_hash.as_str())
|
.bind(user.password_hash.as_str())
|
||||||
|
.bind(&user.role)
|
||||||
.bind(user.created_at)
|
.bind(user.created_at)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
@@ -109,6 +113,14 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
.map_pg()?;
|
.map_pg()?;
|
||||||
Ok(())
|
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 ---
|
// --- PostgresRefreshTokenRepository ---
|
||||||
|
|||||||
@@ -405,6 +405,17 @@ impl PluginRepository for PostgresPluginRepository {
|
|||||||
Ok(row.map(Into::into))
|
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> {
|
async fn find_enabled(&self) -> Result<Vec<Plugin>, DomainError> {
|
||||||
let rows = sqlx::query_as::<_, PluginRow>(
|
let rows = sqlx::query_as::<_, PluginRow>(
|
||||||
"SELECT plugin_id, name, plugin_type, is_enabled, configuration
|
"SELECT plugin_id, name, plugin_type, is_enabled, configuration
|
||||||
@@ -521,6 +532,17 @@ impl PipelineRepository for PostgresPipelineRepository {
|
|||||||
Ok(row.map(Into::into))
|
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> {
|
async fn find_by_trigger(&self, event: &str) -> Result<Vec<ProcessingPipeline>, DomainError> {
|
||||||
let rows = sqlx::query_as::<_, PipelineRow>(
|
let rows = sqlx::query_as::<_, PipelineRow>(
|
||||||
"SELECT pipeline_id, trigger_event, steps
|
"SELECT pipeline_id, trigger_event, steps
|
||||||
|
|||||||
@@ -160,6 +160,18 @@ impl LibraryPathRepository for PostgresLibraryPathRepository {
|
|||||||
Ok(row.map(Into::into))
|
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> {
|
async fn find_by_volume(&self, volume_id: &SystemId) -> Result<Vec<LibraryPath>, DomainError> {
|
||||||
let rows = sqlx::query_as::<_, LibraryPathRow>(
|
let rows = sqlx::query_as::<_, LibraryPathRow>(
|
||||||
"SELECT path_id, volume_id, relative_path, is_ingest_destination, ownership_policy, designated_owner_id
|
"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>(
|
let rows = sqlx::query_as::<_, LibraryPathRow>(
|
||||||
"SELECT path_id, volume_id, relative_path, is_ingest_destination, ownership_policy, designated_owner_id
|
"SELECT path_id, volume_id, relative_path, is_ingest_destination, ownership_policy, designated_owner_id
|
||||||
FROM library_paths
|
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())
|
.bind(*owner_id.as_uuid())
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
pub mod adapter;
|
pub mod adapter;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod local_file_storage;
|
pub mod local_file_storage;
|
||||||
|
pub mod volume_resolver;
|
||||||
|
|
||||||
pub use adapter::ObjectStorageAdapter;
|
pub use adapter::ObjectStorageAdapter;
|
||||||
pub use config::{StorageConfig, build_store};
|
pub use config::{StorageConfig, build_store};
|
||||||
pub use local_file_storage::LocalFileStorage;
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ pub struct UserResponse {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
|
pub role: String,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ impl UserResponse {
|
|||||||
id: *user.id.as_uuid(),
|
id: *user.id.as_uuid(),
|
||||||
username: user.username.clone(),
|
username: user.username.clone(),
|
||||||
email: user.email.to_string(),
|
email: user.email.to_string(),
|
||||||
|
role: user.role.clone(),
|
||||||
created_at: user.created_at,
|
created_at: user.created_at,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,6 +36,7 @@ pub struct AlbumResponse {
|
|||||||
pub description: String,
|
pub description: String,
|
||||||
pub creator_id: Uuid,
|
pub creator_id: Uuid,
|
||||||
pub asset_count: usize,
|
pub asset_count: usize,
|
||||||
|
pub asset_ids: Vec<Uuid>,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +48,7 @@ impl AlbumResponse {
|
|||||||
description: album.description.clone(),
|
description: album.description.clone(),
|
||||||
creator_id: *album.creator_user_id.as_uuid(),
|
creator_id: *album.creator_user_id.as_uuid(),
|
||||||
asset_count: album.asset_count(),
|
asset_count: album.asset_count(),
|
||||||
|
asset_ids: album.entries.iter().map(|e| *e.asset_id.as_uuid()).collect(),
|
||||||
created_at: *album.created_at.as_datetime(),
|
created_at: *album.created_at.as_datetime(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,6 +88,17 @@ impl AssetResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
|
||||||
|
pub struct DateSummaryResponse {
|
||||||
|
pub dates: Vec<DateCountEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
|
||||||
|
pub struct DateCountEntry {
|
||||||
|
pub date: String,
|
||||||
|
pub count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
|
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
|
||||||
pub struct TimelineResponse {
|
pub struct TimelineResponse {
|
||||||
pub assets: Vec<AssetResponse>,
|
pub assets: Vec<AssetResponse>,
|
||||||
@@ -349,6 +364,7 @@ pub struct JobResponse {
|
|||||||
pub status: String,
|
pub status: String,
|
||||||
pub priority: u32,
|
pub priority: u32,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub error_message: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl JobResponse {
|
impl JobResponse {
|
||||||
@@ -359,6 +375,7 @@ impl JobResponse {
|
|||||||
status: format!("{:?}", job.status),
|
status: format!("{:?}", job.status),
|
||||||
priority: job.priority,
|
priority: job.priority,
|
||||||
created_at: *job.created_at.as_datetime(),
|
created_at: *job.created_at.as_datetime(),
|
||||||
|
error_message: job.error_message.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ pub use commands::resolve_duplicate::{
|
|||||||
};
|
};
|
||||||
pub use commands::update_metadata::{UpdateMetadataCommand, UpdateMetadataHandler};
|
pub use commands::update_metadata::{UpdateMetadataCommand, UpdateMetadataHandler};
|
||||||
pub use queries::get_asset::{GetAssetHandler, GetAssetQuery};
|
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_stack::{GetStackHandler, GetStackQuery};
|
||||||
pub use queries::get_timeline::{GetTimelineHandler, GetTimelineQuery, TimelineResult};
|
pub use queries::get_timeline::{GetTimelineHandler, GetTimelineQuery, TimelineResult};
|
||||||
pub use queries::list_stacks::{ListStacksHandler, ListStacksQuery};
|
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_asset;
|
||||||
|
pub mod get_date_summary;
|
||||||
pub mod get_stack;
|
pub mod get_stack;
|
||||||
pub mod get_timeline;
|
pub mod get_timeline;
|
||||||
pub mod list_stacks;
|
pub mod list_stacks;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
ports::{AssetRepository, DataStream, FileStoragePort},
|
ports::{AssetRepository, DataStream, VolumeFileResolver},
|
||||||
value_objects::SystemId,
|
value_objects::SystemId,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -20,17 +20,17 @@ pub struct AssetFileResult {
|
|||||||
|
|
||||||
pub struct ReadAssetFileHandler {
|
pub struct ReadAssetFileHandler {
|
||||||
asset_repo: Arc<dyn AssetRepository>,
|
asset_repo: Arc<dyn AssetRepository>,
|
||||||
file_storage: Arc<dyn FileStoragePort>,
|
volume_resolver: Arc<dyn VolumeFileResolver>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ReadAssetFileHandler {
|
impl ReadAssetFileHandler {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
asset_repo: Arc<dyn AssetRepository>,
|
asset_repo: Arc<dyn AssetRepository>,
|
||||||
file_storage: Arc<dyn FileStoragePort>,
|
volume_resolver: Arc<dyn VolumeFileResolver>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
asset_repo,
|
asset_repo,
|
||||||
file_storage,
|
volume_resolver,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,8 +46,11 @@ impl ReadAssetFileHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let (stream, size) = self
|
let (stream, size) = self
|
||||||
.file_storage
|
.volume_resolver
|
||||||
.open_file(&asset.source_reference.relative_path)
|
.open_by_volume(
|
||||||
|
&asset.source_reference.volume_id,
|
||||||
|
&asset.source_reference.relative_path,
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let filename = asset
|
let filename = asset
|
||||||
|
|||||||
@@ -134,6 +134,13 @@ impl AssetRepository for VisibilityFilteredAssetRepository {
|
|||||||
self.inner.count_search(owner_id, filters).await
|
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> {
|
async fn save(&self, asset: &Asset) -> Result<(), DomainError> {
|
||||||
self.inner.save(asset).await
|
self.inner.save(asset).await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ impl LoginUserHandler {
|
|||||||
if !valid {
|
if !valid {
|
||||||
return Err(DomainError::Unauthorized("Invalid credentials".to_string()));
|
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?;
|
let (raw_refresh, _) = generate_refresh_token(&self.refresh_repo, &user.id).await?;
|
||||||
Ok((user, access_token, raw_refresh))
|
Ok((user, access_token, raw_refresh))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use super::login_user::generate_refresh_token;
|
use super::login_user::generate_refresh_token;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
ports::{RefreshTokenRepository, TokenIssuer},
|
ports::{RefreshTokenRepository, TokenIssuer, UserRepository},
|
||||||
};
|
};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -13,16 +13,19 @@ pub struct RefreshTokenCommand {
|
|||||||
|
|
||||||
pub struct RefreshTokenHandler {
|
pub struct RefreshTokenHandler {
|
||||||
refresh_repo: Arc<dyn RefreshTokenRepository>,
|
refresh_repo: Arc<dyn RefreshTokenRepository>,
|
||||||
|
user_repo: Arc<dyn UserRepository>,
|
||||||
issuer: Arc<dyn TokenIssuer>,
|
issuer: Arc<dyn TokenIssuer>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RefreshTokenHandler {
|
impl RefreshTokenHandler {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
refresh_repo: Arc<dyn RefreshTokenRepository>,
|
refresh_repo: Arc<dyn RefreshTokenRepository>,
|
||||||
|
user_repo: Arc<dyn UserRepository>,
|
||||||
issuer: Arc<dyn TokenIssuer>,
|
issuer: Arc<dyn TokenIssuer>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
refresh_repo,
|
refresh_repo,
|
||||||
|
user_repo,
|
||||||
issuer,
|
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?;
|
self.refresh_repo.delete(&token.token_id).await?;
|
||||||
|
|
||||||
let access_token = self.issuer.issue(&token.user_id, "user").await?;
|
let access_token = self.issuer.issue(&user.id, &user.role).await?;
|
||||||
let (raw_refresh, _) = generate_refresh_token(&self.refresh_repo, &token.user_id).await?;
|
let (raw_refresh, _) = generate_refresh_token(&self.refresh_repo, &user.id).await?;
|
||||||
|
|
||||||
Ok((access_token, raw_refresh))
|
Ok((access_token, raw_refresh))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,11 @@ impl RegisterUserHandler {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
let hash = self.hasher.hash(&cmd.password).await?;
|
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?;
|
self.user_repo.save(&user).await?;
|
||||||
Ok(user)
|
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::process_next_job::{ProcessNextJobCommand, ProcessNextJobHandler};
|
||||||
pub use commands::start_job::{StartJobCommand, StartJobHandler};
|
pub use commands::start_job::{StartJobCommand, StartJobHandler};
|
||||||
pub use queries::list_jobs::{JobListResult, ListJobsHandler, ListJobsQuery};
|
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::{
|
pub use queries::report_batch_progress::{
|
||||||
BatchProgress, ReportBatchProgressHandler, ReportBatchProgressQuery,
|
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_jobs;
|
||||||
|
pub mod list_pipelines;
|
||||||
|
pub mod list_plugins;
|
||||||
pub mod report_batch_progress;
|
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 ingest_asset;
|
||||||
pub mod register_library_path;
|
pub mod register_library_path;
|
||||||
pub mod register_volume;
|
pub mod register_volume;
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod queries;
|
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::ingest_asset::{IngestAssetCommand, IngestAssetHandler};
|
||||||
pub use commands::register_library_path::{RegisterLibraryPathCommand, RegisterLibraryPathHandler};
|
pub use commands::register_library_path::{RegisterLibraryPathCommand, RegisterLibraryPathHandler};
|
||||||
pub use commands::register_volume::{RegisterVolumeCommand, RegisterVolumeHandler};
|
pub use commands::register_volume::{RegisterVolumeCommand, RegisterVolumeHandler};
|
||||||
pub use queries::check_quota::{CheckQuotaHandler, CheckQuotaQuery};
|
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 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());
|
self.users.lock().await.remove(&id.to_string());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn count(&self) -> Result<u64, DomainError> {
|
||||||
|
Ok(self.users.lock().await.len() as u64)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
in_memory_repo!(InMemoryAssetRepository, Asset);
|
in_memory_repo!(InMemoryAssetRepository, Asset);
|
||||||
@@ -173,6 +177,21 @@ impl AssetRepository for InMemoryAssetRepository {
|
|||||||
self.count_by_owner(owner_id).await
|
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> {
|
async fn save(&self, asset: &Asset) -> Result<(), DomainError> {
|
||||||
self.data
|
self.data
|
||||||
.lock()
|
.lock()
|
||||||
@@ -385,6 +404,10 @@ impl LibraryPathRepository for InMemoryLibraryPathRepository {
|
|||||||
Ok(self.data.lock().await.get(&id.to_string()).cloned())
|
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> {
|
async fn find_by_volume(&self, volume_id: &SystemId) -> Result<Vec<LibraryPath>, DomainError> {
|
||||||
Ok(self
|
Ok(self
|
||||||
.data
|
.data
|
||||||
@@ -918,6 +941,10 @@ impl PluginRepository for InMemoryPluginRepository {
|
|||||||
Ok(self.data.lock().await.get(&id.to_string()).cloned())
|
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> {
|
async fn find_enabled(&self) -> Result<Vec<Plugin>, DomainError> {
|
||||||
Ok(self
|
Ok(self
|
||||||
.data
|
.data
|
||||||
@@ -946,6 +973,10 @@ impl PipelineRepository for InMemoryPipelineRepository {
|
|||||||
Ok(self.data.lock().await.get(&id.to_string()).cloned())
|
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> {
|
async fn find_by_trigger(&self, event: &str) -> Result<Vec<ProcessingPipeline>, DomainError> {
|
||||||
Ok(self
|
Ok(self
|
||||||
.data
|
.data
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ pub struct Config {
|
|||||||
pub nats_url: String,
|
pub nats_url: String,
|
||||||
pub jwt_secret: String,
|
pub jwt_secret: String,
|
||||||
pub cors_allowed_origins: Vec<String>,
|
pub cors_allowed_origins: Vec<String>,
|
||||||
|
pub max_upload_bytes: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
@@ -26,6 +27,10 @@ impl Config {
|
|||||||
.split(',')
|
.split(',')
|
||||||
.map(|s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
.collect(),
|
.collect(),
|
||||||
|
max_upload_bytes: std::env::var("MAX_UPLOAD_BYTES")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(256 * 1024 * 1024),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
use axum::extract::DefaultBodyLimit;
|
||||||
use axum::http::HeaderValue;
|
use axum::http::HeaderValue;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tower_http::{
|
use tower_http::{
|
||||||
@@ -32,7 +33,8 @@ pub async fn build_app(config: &Config) -> Result<Router> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let storage_path = std::env::var("STORAGE_PATH").unwrap_or_else(|_| "./data/media".to_string());
|
let storage_path = std::env::var("STORAGE_PATH").unwrap_or_else(|_| "./data/media".to_string());
|
||||||
let file_storage: Arc<LocalFileStorage> = Arc::new(LocalFileStorage::new(&storage_path));
|
let file_storage: Arc<dyn domain::ports::FileStoragePort> =
|
||||||
|
Arc::new(LocalFileStorage::new(&storage_path));
|
||||||
|
|
||||||
// Build per-context services
|
// Build per-context services
|
||||||
let identity = services::identity::build(&pool, &config.jwt_secret);
|
let identity = services::identity::build(&pool, &config.jwt_secret);
|
||||||
@@ -68,6 +70,7 @@ pub async fn build_app(config: &Config) -> Result<Router> {
|
|||||||
|
|
||||||
Ok(app_router(&state)
|
Ok(app_router(&state)
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
|
.layer(DefaultBodyLimit::max(config.max_upload_bytes))
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.layer(cors))
|
.layer(cors))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ use adapters_postgres::{
|
|||||||
PostgresDerivativeRepository, PostgresDuplicateRepository, PostgresIngestTransaction,
|
PostgresDerivativeRepository, PostgresDuplicateRepository, PostgresIngestTransaction,
|
||||||
PostgresSidecarRepository,
|
PostgresSidecarRepository,
|
||||||
};
|
};
|
||||||
use adapters_storage::LocalFileStorage;
|
use adapters_storage::LocalVolumeFileResolver;
|
||||||
|
use domain::ports::FileStoragePort;
|
||||||
use application::catalog::{
|
use application::catalog::{
|
||||||
CreateStackHandler, DeleteAssetHandler, DeleteStackHandler, DetectLivePhotosHandler,
|
CreateStackHandler, DeleteAssetHandler, DeleteStackHandler, DetectLivePhotosHandler,
|
||||||
GetAssetHandler, GetStackHandler, GetTimelineHandler, ListDuplicatesHandler,
|
GetAssetHandler, GetDateSummaryHandler, GetStackHandler, GetTimelineHandler,
|
||||||
|
ListDuplicatesHandler,
|
||||||
ReadAssetFileHandler, ReadDerivativeHandler, RegisterAssetHandler, ResolveDuplicateHandler,
|
ReadAssetFileHandler, ReadDerivativeHandler, RegisterAssetHandler, ResolveDuplicateHandler,
|
||||||
SearchAssetsHandler, UpdateMetadataHandler,
|
SearchAssetsHandler, UpdateMetadataHandler,
|
||||||
};
|
};
|
||||||
@@ -21,7 +23,7 @@ use super::storage::StorageRepos;
|
|||||||
pub fn build(
|
pub fn build(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
storage_repos: &StorageRepos,
|
storage_repos: &StorageRepos,
|
||||||
file_storage: Arc<LocalFileStorage>,
|
file_storage: Arc<dyn FileStoragePort>,
|
||||||
event_publisher: Arc<dyn EventPublisher>,
|
event_publisher: Arc<dyn EventPublisher>,
|
||||||
) -> CatalogHandlers {
|
) -> CatalogHandlers {
|
||||||
let asset_repo = Arc::new(PostgresAssetRepository::new(pool.clone()));
|
let asset_repo = Arc::new(PostgresAssetRepository::new(pool.clone()));
|
||||||
@@ -49,15 +51,20 @@ pub fn build(
|
|||||||
metadata_repo.clone(),
|
metadata_repo.clone(),
|
||||||
));
|
));
|
||||||
|
|
||||||
|
let get_date_summary = Arc::new(GetDateSummaryHandler::new(asset_repo.clone()));
|
||||||
|
|
||||||
let update_metadata = Arc::new(UpdateMetadataHandler::new(
|
let update_metadata = Arc::new(UpdateMetadataHandler::new(
|
||||||
asset_repo.clone(),
|
asset_repo.clone(),
|
||||||
metadata_repo.clone(),
|
metadata_repo.clone(),
|
||||||
event_publisher.clone(),
|
event_publisher.clone(),
|
||||||
));
|
));
|
||||||
|
|
||||||
|
let volume_resolver = Arc::new(LocalVolumeFileResolver::new(
|
||||||
|
storage_repos.volume_repo.clone(),
|
||||||
|
));
|
||||||
let read_asset_file = Arc::new(ReadAssetFileHandler::new(
|
let read_asset_file = Arc::new(ReadAssetFileHandler::new(
|
||||||
asset_repo.clone(),
|
asset_repo.clone(),
|
||||||
file_storage.clone(),
|
volume_resolver,
|
||||||
));
|
));
|
||||||
|
|
||||||
let read_derivative = Arc::new(ReadDerivativeHandler::new(
|
let read_derivative = Arc::new(ReadDerivativeHandler::new(
|
||||||
@@ -103,6 +110,7 @@ pub fn build(
|
|||||||
ingest_asset,
|
ingest_asset,
|
||||||
get_asset,
|
get_asset,
|
||||||
get_timeline,
|
get_timeline,
|
||||||
|
get_date_summary,
|
||||||
update_metadata,
|
update_metadata,
|
||||||
read_asset_file,
|
read_asset_file,
|
||||||
read_derivative,
|
read_derivative,
|
||||||
|
|||||||
@@ -26,9 +26,10 @@ pub fn build(pool: &PgPool, jwt_secret: &str) -> IdentityServices {
|
|||||||
issuer.clone(),
|
issuer.clone(),
|
||||||
refresh_repo.clone(),
|
refresh_repo.clone(),
|
||||||
));
|
));
|
||||||
let get_profile = Arc::new(GetProfileHandler::new(user_repo));
|
let get_profile = Arc::new(GetProfileHandler::new(user_repo.clone()));
|
||||||
let refresh = Arc::new(RefreshTokenHandler::new(
|
let refresh = Arc::new(RefreshTokenHandler::new(
|
||||||
refresh_repo.clone(),
|
refresh_repo.clone(),
|
||||||
|
user_repo,
|
||||||
issuer.clone(),
|
issuer.clone(),
|
||||||
));
|
));
|
||||||
let logout = Arc::new(LogoutHandler::new(refresh_repo.clone()));
|
let logout = Arc::new(LogoutHandler::new(refresh_repo.clone()));
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ use adapters_postgres::{
|
|||||||
};
|
};
|
||||||
use application::processing::{
|
use application::processing::{
|
||||||
CompleteJobHandler, ConfigurePipelineHandler, EnqueueJobHandler, FailJobHandler,
|
CompleteJobHandler, ConfigurePipelineHandler, EnqueueJobHandler, FailJobHandler,
|
||||||
ListJobsHandler, ManagePluginHandler, ReportBatchProgressHandler, StartJobHandler,
|
ListJobsHandler, ListPipelinesHandler, ListPluginsHandler, ManagePluginHandler,
|
||||||
|
ReportBatchProgressHandler, StartJobHandler,
|
||||||
};
|
};
|
||||||
use domain::ports::EventPublisher;
|
use domain::ports::EventPublisher;
|
||||||
use presentation::state::ProcessingHandlers;
|
use presentation::state::ProcessingHandlers;
|
||||||
@@ -35,6 +36,8 @@ pub fn build(pool: &PgPool, event_publisher: Arc<dyn EventPublisher>) -> Process
|
|||||||
let list_jobs = Arc::new(ListJobsHandler::new(job_repo.clone()));
|
let list_jobs = Arc::new(ListJobsHandler::new(job_repo.clone()));
|
||||||
let batch_progress = Arc::new(ReportBatchProgressHandler::new(batch_repo, job_repo));
|
let batch_progress = Arc::new(ReportBatchProgressHandler::new(batch_repo, job_repo));
|
||||||
let manage_plugin = Arc::new(ManagePluginHandler::new(plugin_repo.clone()));
|
let manage_plugin = Arc::new(ManagePluginHandler::new(plugin_repo.clone()));
|
||||||
|
let list_plugins = Arc::new(ListPluginsHandler::new(plugin_repo.clone()));
|
||||||
|
let list_pipelines = Arc::new(ListPipelinesHandler::new(pipeline_repo.clone()));
|
||||||
let configure_pipeline = Arc::new(ConfigurePipelineHandler::new(pipeline_repo, plugin_repo));
|
let configure_pipeline = Arc::new(ConfigurePipelineHandler::new(pipeline_repo, plugin_repo));
|
||||||
|
|
||||||
ProcessingHandlers {
|
ProcessingHandlers {
|
||||||
@@ -45,6 +48,8 @@ pub fn build(pool: &PgPool, event_publisher: Arc<dyn EventPublisher>) -> Process
|
|||||||
list_jobs,
|
list_jobs,
|
||||||
batch_progress,
|
batch_progress,
|
||||||
manage_plugin,
|
manage_plugin,
|
||||||
|
list_plugins,
|
||||||
configure_pipeline,
|
configure_pipeline,
|
||||||
|
list_pipelines,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,17 @@ use adapters_postgres::{
|
|||||||
PgPool, PostgresLibraryPathRepository, PostgresQuotaRepository,
|
PgPool, PostgresLibraryPathRepository, PostgresQuotaRepository,
|
||||||
PostgresStorageVolumeRepository, PostgresUsageLedgerRepository,
|
PostgresStorageVolumeRepository, PostgresUsageLedgerRepository,
|
||||||
};
|
};
|
||||||
use application::storage::{CheckQuotaHandler, RegisterLibraryPathHandler, RegisterVolumeHandler};
|
use application::storage::{
|
||||||
|
CheckQuotaHandler, DeleteLibraryPathHandler, DeleteVolumeHandler, ListAllLibraryPathsHandler,
|
||||||
|
ListIngestPathsHandler, ListVolumesHandler, RegisterLibraryPathHandler,
|
||||||
|
RegisterVolumeHandler,
|
||||||
|
};
|
||||||
use presentation::state::StorageHandlers;
|
use presentation::state::StorageHandlers;
|
||||||
|
|
||||||
/// Shared storage repos needed by other bounded contexts (catalog ingest, etc.).
|
/// Shared storage repos needed by other bounded contexts (catalog ingest, etc.).
|
||||||
pub struct StorageRepos {
|
pub struct StorageRepos {
|
||||||
pub path_repo: Arc<PostgresLibraryPathRepository>,
|
pub path_repo: Arc<dyn domain::ports::LibraryPathRepository>,
|
||||||
|
pub volume_repo: Arc<dyn domain::ports::StorageVolumeRepository>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build(pool: &PgPool) -> (StorageRepos, StorageHandlers) {
|
pub fn build(pool: &PgPool) -> (StorageRepos, StorageHandlers) {
|
||||||
@@ -18,20 +23,33 @@ pub fn build(pool: &PgPool) -> (StorageRepos, StorageHandlers) {
|
|||||||
let quota_repo = Arc::new(PostgresQuotaRepository::new(pool.clone()));
|
let quota_repo = Arc::new(PostgresQuotaRepository::new(pool.clone()));
|
||||||
let ledger_repo = Arc::new(PostgresUsageLedgerRepository::new(pool.clone()));
|
let ledger_repo = Arc::new(PostgresUsageLedgerRepository::new(pool.clone()));
|
||||||
|
|
||||||
|
let list_volumes = Arc::new(ListVolumesHandler::new(volume_repo.clone()));
|
||||||
let register_volume = Arc::new(RegisterVolumeHandler::new(volume_repo.clone()));
|
let register_volume = Arc::new(RegisterVolumeHandler::new(volume_repo.clone()));
|
||||||
|
let delete_volume = Arc::new(DeleteVolumeHandler::new(volume_repo.clone()));
|
||||||
let register_library_path = Arc::new(RegisterLibraryPathHandler::new(
|
let register_library_path = Arc::new(RegisterLibraryPathHandler::new(
|
||||||
volume_repo,
|
volume_repo.clone(),
|
||||||
path_repo.clone(),
|
path_repo.clone(),
|
||||||
));
|
));
|
||||||
|
let list_ingest_paths = Arc::new(ListIngestPathsHandler::new(path_repo.clone()));
|
||||||
|
let list_all_library_paths = Arc::new(ListAllLibraryPathsHandler::new(path_repo.clone()));
|
||||||
|
let delete_library_path = Arc::new(DeleteLibraryPathHandler::new(path_repo.clone()));
|
||||||
let check_quota = Arc::new(CheckQuotaHandler::new(quota_repo, ledger_repo));
|
let check_quota = Arc::new(CheckQuotaHandler::new(quota_repo, ledger_repo));
|
||||||
|
|
||||||
let handlers = StorageHandlers {
|
let handlers = StorageHandlers {
|
||||||
register_volume,
|
register_volume,
|
||||||
|
delete_volume,
|
||||||
|
list_volumes,
|
||||||
register_library_path,
|
register_library_path,
|
||||||
|
list_ingest_paths,
|
||||||
|
list_all_library_paths,
|
||||||
|
delete_library_path,
|
||||||
check_quota,
|
check_quota,
|
||||||
};
|
};
|
||||||
|
|
||||||
let repos = StorageRepos { path_repo };
|
let repos = StorageRepos {
|
||||||
|
path_repo,
|
||||||
|
volume_repo,
|
||||||
|
};
|
||||||
|
|
||||||
(repos, handlers)
|
(repos, handlers)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ pub trait AssetRepository: Send + Sync {
|
|||||||
owner_id: &SystemId,
|
owner_id: &SystemId,
|
||||||
filters: &AssetFilters,
|
filters: &AssetFilters,
|
||||||
) -> Result<u64, DomainError>;
|
) -> Result<u64, DomainError>;
|
||||||
|
async fn date_summary(
|
||||||
|
&self,
|
||||||
|
owner_id: &SystemId,
|
||||||
|
) -> Result<Vec<(chrono::NaiveDate, u64)>, DomainError>;
|
||||||
async fn save(&self, asset: &Asset) -> Result<(), DomainError>;
|
async fn save(&self, asset: &Asset) -> Result<(), DomainError>;
|
||||||
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
|
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ pub struct User {
|
|||||||
pub username: String,
|
pub username: String,
|
||||||
pub email: Email,
|
pub email: Email,
|
||||||
pub password_hash: PasswordHash,
|
pub password_hash: PasswordHash,
|
||||||
|
pub role: String,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,9 +137,14 @@ impl User {
|
|||||||
username: username.into(),
|
username: username.into(),
|
||||||
email,
|
email,
|
||||||
password_hash,
|
password_hash,
|
||||||
|
role: "user".to_string(),
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_admin(&self) -> bool {
|
||||||
|
self.role == "admin"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- RefreshToken ---
|
// --- RefreshToken ---
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub trait UserRepository: Send + Sync {
|
|||||||
async fn find_by_username(&self, username: &str) -> Result<Option<User>, DomainError>;
|
async fn find_by_username(&self, username: &str) -> Result<Option<User>, DomainError>;
|
||||||
async fn save(&self, user: &User) -> Result<(), DomainError>;
|
async fn save(&self, user: &User) -> Result<(), DomainError>;
|
||||||
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
|
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
|
||||||
|
async fn count(&self) -> Result<u64, DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- RoleRepository ---
|
// --- RoleRepository ---
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ pub trait JobBatchRepository: Send + Sync {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait PluginRepository: Send + Sync {
|
pub trait PluginRepository: Send + Sync {
|
||||||
async fn find_by_id(&self, id: &SystemId) -> Result<Option<Plugin>, DomainError>;
|
async fn find_by_id(&self, id: &SystemId) -> Result<Option<Plugin>, DomainError>;
|
||||||
|
async fn find_all(&self) -> Result<Vec<Plugin>, DomainError>;
|
||||||
async fn find_enabled(&self) -> Result<Vec<Plugin>, DomainError>;
|
async fn find_enabled(&self) -> Result<Vec<Plugin>, DomainError>;
|
||||||
async fn save(&self, plugin: &Plugin) -> Result<(), DomainError>;
|
async fn save(&self, plugin: &Plugin) -> Result<(), DomainError>;
|
||||||
}
|
}
|
||||||
@@ -43,6 +44,7 @@ pub trait PluginRepository: Send + Sync {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait PipelineRepository: Send + Sync {
|
pub trait PipelineRepository: Send + Sync {
|
||||||
async fn find_by_id(&self, id: &SystemId) -> Result<Option<ProcessingPipeline>, DomainError>;
|
async fn find_by_id(&self, id: &SystemId) -> Result<Option<ProcessingPipeline>, DomainError>;
|
||||||
|
async fn find_all(&self) -> Result<Vec<ProcessingPipeline>, DomainError>;
|
||||||
async fn find_by_trigger(&self, event: &str) -> Result<Vec<ProcessingPipeline>, DomainError>;
|
async fn find_by_trigger(&self, event: &str) -> Result<Vec<ProcessingPipeline>, DomainError>;
|
||||||
async fn save(&self, pipeline: &ProcessingPipeline) -> Result<(), DomainError>;
|
async fn save(&self, pipeline: &ProcessingPipeline) -> Result<(), DomainError>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ pub trait StorageVolumeRepository: Send + Sync {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait LibraryPathRepository: Send + Sync {
|
pub trait LibraryPathRepository: Send + Sync {
|
||||||
async fn find_by_id(&self, id: &SystemId) -> Result<Option<LibraryPath>, DomainError>;
|
async fn find_by_id(&self, id: &SystemId) -> Result<Option<LibraryPath>, DomainError>;
|
||||||
|
async fn find_all(&self) -> Result<Vec<LibraryPath>, DomainError>;
|
||||||
async fn find_by_volume(&self, volume_id: &SystemId) -> Result<Vec<LibraryPath>, DomainError>;
|
async fn find_by_volume(&self, volume_id: &SystemId) -> Result<Vec<LibraryPath>, DomainError>;
|
||||||
async fn find_ingest_destinations(
|
async fn find_ingest_destinations(
|
||||||
&self,
|
&self,
|
||||||
@@ -84,6 +85,23 @@ pub trait IngestTransaction: Send + Sync {
|
|||||||
async fn record_usage(&self, entry: &UsageLedgerEntry) -> Result<(), DomainError>;
|
async fn record_usage(&self, entry: &UsageLedgerEntry) -> Result<(), DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- VolumeFileResolver ---
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait VolumeFileResolver: Send + Sync {
|
||||||
|
async fn open_by_volume(
|
||||||
|
&self,
|
||||||
|
volume_id: &SystemId,
|
||||||
|
relative_path: &str,
|
||||||
|
) -> Result<(DataStream, u64), DomainError>;
|
||||||
|
|
||||||
|
async fn read_by_volume(
|
||||||
|
&self,
|
||||||
|
volume_id: &SystemId,
|
||||||
|
relative_path: &str,
|
||||||
|
) -> Result<Bytes, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
// --- FileStoragePort ---
|
// --- FileStoragePort ---
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ use crate::{
|
|||||||
};
|
};
|
||||||
use api_types::{
|
use api_types::{
|
||||||
requests::{RegisterAssetRequest, TagAssetRequest},
|
requests::{RegisterAssetRequest, TagAssetRequest},
|
||||||
responses::{AssetResponse, IngestResponse, TagResponse, TimelineResponse},
|
responses::{
|
||||||
|
AssetResponse, DateCountEntry, DateSummaryResponse, IngestResponse, TagResponse,
|
||||||
|
TimelineResponse,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use application::{
|
use application::{
|
||||||
catalog::{
|
catalog::{
|
||||||
DeleteAssetCommand, GetAssetQuery, GetTimelineQuery, ReadAssetFileQuery,
|
DeleteAssetCommand, GetAssetQuery, GetDateSummaryQuery, GetTimelineQuery, ReadAssetFileQuery,
|
||||||
ReadDerivativeQuery, RegisterAssetCommand, SearchAssetsQuery, UpdateMetadataCommand,
|
ReadDerivativeQuery, RegisterAssetCommand, SearchAssetsQuery, UpdateMetadataCommand,
|
||||||
},
|
},
|
||||||
organization::TagAssetCommand,
|
organization::TagAssetCommand,
|
||||||
@@ -225,6 +228,25 @@ pub async fn timeline(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn date_summary(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
claims: JwtClaims,
|
||||||
|
) -> Result<Json<DateSummaryResponse>, AppError> {
|
||||||
|
let query = GetDateSummaryQuery {
|
||||||
|
owner_id: claims.user_id,
|
||||||
|
};
|
||||||
|
let entries = state.catalog.get_date_summary.execute(query).await?;
|
||||||
|
Ok(Json(DateSummaryResponse {
|
||||||
|
dates: entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| DateCountEntry {
|
||||||
|
date: e.date.to_string(),
|
||||||
|
count: e.count,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
put, path = "/api/v1/assets/{id}/metadata",
|
put, path = "/api/v1/assets/{id}/metadata",
|
||||||
request_body = api_types::requests::UpdateMetadataRequest,
|
request_body = api_types::requests::UpdateMetadataRequest,
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ pub async fn register(
|
|||||||
let user = state.identity.register.execute(cmd).await?;
|
let user = state.identity.register.execute(cmd).await?;
|
||||||
let token = state
|
let token = state
|
||||||
.token_issuer
|
.token_issuer
|
||||||
.issue(&user.id, "user")
|
.issue(&user.id, &user.role)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
let (refresh_token, _) =
|
let (refresh_token, _) =
|
||||||
|
|||||||
@@ -198,6 +198,15 @@ pub async fn batch_progress(
|
|||||||
Ok(Json(BatchProgressResponse::from_domain(&progress)))
|
Ok(Json(BatchProgressResponse::from_domain(&progress)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_plugins(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
claims: JwtClaims,
|
||||||
|
) -> Result<Json<Vec<PluginResponse>>, AppError> {
|
||||||
|
super::require_admin(&claims)?;
|
||||||
|
let plugins = state.processing.list_plugins.execute().await?;
|
||||||
|
Ok(Json(plugins.iter().map(PluginResponse::from_domain).collect()))
|
||||||
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post, path = "/api/v1/plugins",
|
post, path = "/api/v1/plugins",
|
||||||
request_body = ManagePluginRequest,
|
request_body = ManagePluginRequest,
|
||||||
@@ -251,6 +260,15 @@ pub async fn manage_plugin(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_pipelines(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
claims: JwtClaims,
|
||||||
|
) -> Result<Json<Vec<PipelineResponse>>, AppError> {
|
||||||
|
super::require_admin(&claims)?;
|
||||||
|
let pipelines = state.processing.list_pipelines.execute().await?;
|
||||||
|
Ok(Json(pipelines.iter().map(PipelineResponse::from_domain).collect()))
|
||||||
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post, path = "/api/v1/pipelines",
|
post, path = "/api/v1/pipelines",
|
||||||
request_body = ConfigurePipelineRequest,
|
request_body = ConfigurePipelineRequest,
|
||||||
|
|||||||
@@ -3,14 +3,35 @@ use api_types::{
|
|||||||
requests::{CheckQuotaParams, RegisterLibraryPathRequest, RegisterVolumeRequest},
|
requests::{CheckQuotaParams, RegisterLibraryPathRequest, RegisterVolumeRequest},
|
||||||
responses::{LibraryPathResponse, QuotaCheckResponse, VolumeResponse},
|
responses::{LibraryPathResponse, QuotaCheckResponse, VolumeResponse},
|
||||||
};
|
};
|
||||||
use application::storage::{CheckQuotaQuery, RegisterLibraryPathCommand, RegisterVolumeCommand};
|
use application::storage::{
|
||||||
|
CheckQuotaQuery, ListIngestPathsQuery, RegisterLibraryPathCommand, RegisterVolumeCommand,
|
||||||
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Query, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
};
|
};
|
||||||
use domain::value_objects::SystemId;
|
use domain::value_objects::SystemId;
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get, path = "/api/v1/storage/volumes",
|
||||||
|
security(("bearer_token" = [])),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "All volumes", body = Vec<VolumeResponse>),
|
||||||
|
(status = 401, description = "Unauthorized")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn list_volumes(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
claims: JwtClaims,
|
||||||
|
) -> Result<Json<Vec<VolumeResponse>>, AppError> {
|
||||||
|
super::require_admin(&claims)?;
|
||||||
|
let volumes = state.storage.list_volumes.execute().await?;
|
||||||
|
Ok(Json(
|
||||||
|
volumes.iter().map(VolumeResponse::from_domain).collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post, path = "/api/v1/storage/volumes",
|
post, path = "/api/v1/storage/volumes",
|
||||||
request_body = RegisterVolumeRequest,
|
request_body = RegisterVolumeRequest,
|
||||||
@@ -66,6 +87,75 @@ pub async fn register_library_path(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get, path = "/api/v1/storage/library-paths",
|
||||||
|
security(("bearer_token" = [])),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Ingest destinations", body = Vec<LibraryPathResponse>),
|
||||||
|
(status = 401, description = "Unauthorized")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn list_ingest_paths(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
claims: JwtClaims,
|
||||||
|
) -> Result<Json<Vec<LibraryPathResponse>>, AppError> {
|
||||||
|
let query = ListIngestPathsQuery {
|
||||||
|
user_id: claims.user_id,
|
||||||
|
};
|
||||||
|
let paths = state.storage.list_ingest_paths.execute(query).await?;
|
||||||
|
Ok(Json(
|
||||||
|
paths.iter().map(LibraryPathResponse::from_domain).collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get, path = "/api/v1/storage/library-paths/all",
|
||||||
|
security(("bearer_token" = [])),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "All library paths", body = Vec<LibraryPathResponse>),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 403, description = "Forbidden")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn list_all_library_paths(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
claims: JwtClaims,
|
||||||
|
) -> Result<Json<Vec<LibraryPathResponse>>, AppError> {
|
||||||
|
super::require_admin(&claims)?;
|
||||||
|
let paths = state.storage.list_all_library_paths.execute().await?;
|
||||||
|
Ok(Json(
|
||||||
|
paths.iter().map(LibraryPathResponse::from_domain).collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_volume(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
claims: JwtClaims,
|
||||||
|
Path((id,)): Path<(uuid::Uuid,)>,
|
||||||
|
) -> Result<StatusCode, AppError> {
|
||||||
|
super::require_admin(&claims)?;
|
||||||
|
state
|
||||||
|
.storage
|
||||||
|
.delete_volume
|
||||||
|
.execute(SystemId::from_uuid(id))
|
||||||
|
.await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_library_path(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
claims: JwtClaims,
|
||||||
|
Path((id,)): Path<(uuid::Uuid,)>,
|
||||||
|
) -> Result<StatusCode, AppError> {
|
||||||
|
super::require_admin(&claims)?;
|
||||||
|
state
|
||||||
|
.storage
|
||||||
|
.delete_library_path
|
||||||
|
.execute(SystemId::from_uuid(id))
|
||||||
|
.await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_QUOTA_USAGE_TYPE: &str = "storage_bytes";
|
const DEFAULT_QUOTA_USAGE_TYPE: &str = "storage_bytes";
|
||||||
const DEFAULT_QUOTA_AMOUNT: u64 = 0;
|
const DEFAULT_QUOTA_AMOUNT: u64 = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ pub fn routes() -> Router<AppState> {
|
|||||||
.route("/assets/ingest", post(assets::ingest))
|
.route("/assets/ingest", post(assets::ingest))
|
||||||
.route("/assets/register", post(assets::register_asset))
|
.route("/assets/register", post(assets::register_asset))
|
||||||
.route("/assets/timeline", get(assets::timeline))
|
.route("/assets/timeline", get(assets::timeline))
|
||||||
|
.route("/assets/date-summary", get(assets::date_summary))
|
||||||
.route(
|
.route(
|
||||||
"/assets/{id}",
|
"/assets/{id}",
|
||||||
get(assets::get_asset).delete(assets::delete_asset),
|
get(assets::get_asset).delete(assets::delete_asset),
|
||||||
|
|||||||
@@ -14,6 +14,6 @@ pub fn routes() -> Router<AppState> {
|
|||||||
.route("/jobs/{id}/complete", post(processing::complete_job))
|
.route("/jobs/{id}/complete", post(processing::complete_job))
|
||||||
.route("/jobs/{id}/fail", post(processing::fail_job))
|
.route("/jobs/{id}/fail", post(processing::fail_job))
|
||||||
.route("/jobs/batches/{id}", get(processing::batch_progress))
|
.route("/jobs/batches/{id}", get(processing::batch_progress))
|
||||||
.route("/plugins", post(processing::manage_plugin))
|
.route("/plugins", get(processing::list_plugins).post(processing::manage_plugin))
|
||||||
.route("/pipelines", post(processing::configure_pipeline))
|
.route("/pipelines", get(processing::list_pipelines).post(processing::configure_pipeline))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
use crate::{handlers::storage, state::AppState};
|
use crate::{handlers::storage, state::AppState};
|
||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Router,
|
||||||
routing::{get, post},
|
routing::{delete, get, post},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Router<AppState> {
|
pub fn routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/storage/volumes", post(storage::register_volume))
|
.route(
|
||||||
|
"/storage/volumes",
|
||||||
|
get(storage::list_volumes).post(storage::register_volume),
|
||||||
|
)
|
||||||
|
.route("/storage/volumes/{id}", delete(storage::delete_volume))
|
||||||
.route(
|
.route(
|
||||||
"/storage/library-paths",
|
"/storage/library-paths",
|
||||||
post(storage::register_library_path),
|
get(storage::list_ingest_paths).post(storage::register_library_path),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/storage/library-paths/all",
|
||||||
|
get(storage::list_all_library_paths),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/storage/library-paths/{id}",
|
||||||
|
delete(storage::delete_library_path),
|
||||||
)
|
)
|
||||||
.route("/storage/quota", get(storage::check_quota))
|
.route("/storage/quota", get(storage::check_quota))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ use std::sync::Arc;
|
|||||||
use application::{
|
use application::{
|
||||||
catalog::{
|
catalog::{
|
||||||
CreateStackHandler, DeleteAssetHandler, DeleteStackHandler, DetectLivePhotosHandler,
|
CreateStackHandler, DeleteAssetHandler, DeleteStackHandler, DetectLivePhotosHandler,
|
||||||
GetAssetHandler, GetStackHandler, GetTimelineHandler, ListDuplicatesHandler,
|
GetAssetHandler, GetDateSummaryHandler, GetStackHandler, GetTimelineHandler,
|
||||||
ListStacksHandler, ReadAssetFileHandler, ReadDerivativeHandler, RegisterAssetHandler,
|
ListDuplicatesHandler, ListStacksHandler, ReadAssetFileHandler, ReadDerivativeHandler,
|
||||||
ResolveDuplicateHandler, SearchAssetsHandler, UpdateMetadataHandler,
|
RegisterAssetHandler, ResolveDuplicateHandler, SearchAssetsHandler,
|
||||||
|
UpdateMetadataHandler,
|
||||||
},
|
},
|
||||||
identity::{
|
identity::{
|
||||||
GetProfileHandler, LoginUserHandler, LogoutHandler, RefreshTokenHandler,
|
GetProfileHandler, LoginUserHandler, LogoutHandler, RefreshTokenHandler,
|
||||||
@@ -17,7 +18,8 @@ use application::{
|
|||||||
},
|
},
|
||||||
processing::{
|
processing::{
|
||||||
CompleteJobHandler, ConfigurePipelineHandler, EnqueueJobHandler, FailJobHandler,
|
CompleteJobHandler, ConfigurePipelineHandler, EnqueueJobHandler, FailJobHandler,
|
||||||
ListJobsHandler, ManagePluginHandler, ReportBatchProgressHandler, StartJobHandler,
|
ListJobsHandler, ListPipelinesHandler, ListPluginsHandler, ManagePluginHandler,
|
||||||
|
ReportBatchProgressHandler, StartJobHandler,
|
||||||
},
|
},
|
||||||
sharing::{
|
sharing::{
|
||||||
AccessSharedResourceHandler, GenerateShareLinkHandler, RevokeShareHandler,
|
AccessSharedResourceHandler, GenerateShareLinkHandler, RevokeShareHandler,
|
||||||
@@ -28,7 +30,9 @@ use application::{
|
|||||||
ImportSidecarHandler, ResolveConflictHandler,
|
ImportSidecarHandler, ResolveConflictHandler,
|
||||||
},
|
},
|
||||||
storage::{
|
storage::{
|
||||||
CheckQuotaHandler, IngestAssetHandler, RegisterLibraryPathHandler, RegisterVolumeHandler,
|
CheckQuotaHandler, DeleteLibraryPathHandler, DeleteVolumeHandler, IngestAssetHandler,
|
||||||
|
ListAllLibraryPathsHandler, ListIngestPathsHandler, ListVolumesHandler,
|
||||||
|
RegisterLibraryPathHandler, RegisterVolumeHandler,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use domain::ports::{RefreshTokenRepository, TokenIssuer};
|
use domain::ports::{RefreshTokenRepository, TokenIssuer};
|
||||||
@@ -48,6 +52,7 @@ pub struct CatalogHandlers {
|
|||||||
pub ingest_asset: Arc<IngestAssetHandler>,
|
pub ingest_asset: Arc<IngestAssetHandler>,
|
||||||
pub get_asset: Arc<GetAssetHandler>,
|
pub get_asset: Arc<GetAssetHandler>,
|
||||||
pub get_timeline: Arc<GetTimelineHandler>,
|
pub get_timeline: Arc<GetTimelineHandler>,
|
||||||
|
pub get_date_summary: Arc<GetDateSummaryHandler>,
|
||||||
pub update_metadata: Arc<UpdateMetadataHandler>,
|
pub update_metadata: Arc<UpdateMetadataHandler>,
|
||||||
pub read_asset_file: Arc<ReadAssetFileHandler>,
|
pub read_asset_file: Arc<ReadAssetFileHandler>,
|
||||||
pub read_derivative: Arc<ReadDerivativeHandler>,
|
pub read_derivative: Arc<ReadDerivativeHandler>,
|
||||||
@@ -76,7 +81,12 @@ pub struct OrganizationHandlers {
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct StorageHandlers {
|
pub struct StorageHandlers {
|
||||||
pub register_volume: Arc<RegisterVolumeHandler>,
|
pub register_volume: Arc<RegisterVolumeHandler>,
|
||||||
|
pub delete_volume: Arc<DeleteVolumeHandler>,
|
||||||
|
pub list_volumes: Arc<ListVolumesHandler>,
|
||||||
pub register_library_path: Arc<RegisterLibraryPathHandler>,
|
pub register_library_path: Arc<RegisterLibraryPathHandler>,
|
||||||
|
pub list_ingest_paths: Arc<ListIngestPathsHandler>,
|
||||||
|
pub list_all_library_paths: Arc<ListAllLibraryPathsHandler>,
|
||||||
|
pub delete_library_path: Arc<DeleteLibraryPathHandler>,
|
||||||
pub check_quota: Arc<CheckQuotaHandler>,
|
pub check_quota: Arc<CheckQuotaHandler>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +117,9 @@ pub struct ProcessingHandlers {
|
|||||||
pub list_jobs: Arc<ListJobsHandler>,
|
pub list_jobs: Arc<ListJobsHandler>,
|
||||||
pub batch_progress: Arc<ReportBatchProgressHandler>,
|
pub batch_progress: Arc<ReportBatchProgressHandler>,
|
||||||
pub manage_plugin: Arc<ManagePluginHandler>,
|
pub manage_plugin: Arc<ManagePluginHandler>,
|
||||||
|
pub list_plugins: Arc<ListPluginsHandler>,
|
||||||
pub configure_pipeline: Arc<ConfigurePipelineHandler>,
|
pub configure_pipeline: Arc<ConfigurePipelineHandler>,
|
||||||
|
pub list_pipelines: Arc<ListPipelinesHandler>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use crate::plugins::{
|
|||||||
DirectoryScannerPlugin, MetadataExtractorPlugin, NoOpPlugin, SidecarSyncPlugin,
|
DirectoryScannerPlugin, MetadataExtractorPlugin, NoOpPlugin, SidecarSyncPlugin,
|
||||||
ThumbnailGeneratorPlugin,
|
ThumbnailGeneratorPlugin,
|
||||||
};
|
};
|
||||||
|
use adapters_storage::LocalVolumeFileResolver;
|
||||||
use application::catalog::RegisterAssetHandler;
|
use application::catalog::RegisterAssetHandler;
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
EventPublisher, MetadataExtractorPort, SidecarWriterPort, ThumbnailGeneratorPort,
|
EventPublisher, MetadataExtractorPort, SidecarWriterPort, ThumbnailGeneratorPort,
|
||||||
@@ -21,15 +22,18 @@ pub fn build_plugin_registry(
|
|||||||
) -> InMemoryPluginRegistry {
|
) -> InMemoryPluginRegistry {
|
||||||
let mut registry = InMemoryPluginRegistry::new();
|
let mut registry = InMemoryPluginRegistry::new();
|
||||||
|
|
||||||
|
let volume_resolver = Arc::new(LocalVolumeFileResolver::new(repos.volume.clone()));
|
||||||
|
|
||||||
registry.register(Arc::new(NoOpPlugin));
|
registry.register(Arc::new(NoOpPlugin));
|
||||||
registry.register(Arc::new(MetadataExtractorPlugin::new(
|
registry.register(Arc::new(MetadataExtractorPlugin::new(
|
||||||
repos.asset.clone(),
|
repos.asset.clone(),
|
||||||
file_storage.clone(),
|
volume_resolver.clone(),
|
||||||
repos.metadata.clone(),
|
repos.metadata.clone(),
|
||||||
extractor,
|
extractor,
|
||||||
)));
|
)));
|
||||||
registry.register(Arc::new(ThumbnailGeneratorPlugin::new(
|
registry.register(Arc::new(ThumbnailGeneratorPlugin::new(
|
||||||
repos.asset.clone(),
|
repos.asset.clone(),
|
||||||
|
volume_resolver,
|
||||||
file_storage.clone(),
|
file_storage.clone(),
|
||||||
repos.derivative.clone(),
|
repos.derivative.clone(),
|
||||||
thumbnail_gen,
|
thumbnail_gen,
|
||||||
@@ -43,7 +47,6 @@ pub fn build_plugin_registry(
|
|||||||
registry.register(Arc::new(DirectoryScannerPlugin::new(
|
registry.register(Arc::new(DirectoryScannerPlugin::new(
|
||||||
repos.volume.clone(),
|
repos.volume.clone(),
|
||||||
repos.library_path.clone(),
|
repos.library_path.clone(),
|
||||||
file_storage.clone(),
|
|
||||||
register_handler,
|
register_handler,
|
||||||
)));
|
)));
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ mod sweep;
|
|||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(
|
.with_env_filter(
|
||||||
tracing_subscriber::EnvFilter::from_default_env().add_directive("worker=info".parse()?),
|
tracing_subscriber::EnvFilter::from_default_env().add_directive("worker=info".parse()?),
|
||||||
|
|||||||
@@ -3,17 +3,17 @@ use async_trait::async_trait;
|
|||||||
use domain::{
|
use domain::{
|
||||||
catalog::entities::AssetType,
|
catalog::entities::AssetType,
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
ports::{FileStoragePort, LibraryPathRepository, PluginExecutor, StorageVolumeRepository},
|
ports::{LibraryPathRepository, PluginExecutor, StorageVolumeRepository},
|
||||||
value_objects::{MetadataValue, StructuredData, SystemId},
|
value_objects::{MetadataValue, StructuredData, SystemId},
|
||||||
};
|
};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
pub struct DirectoryScannerPlugin {
|
pub struct DirectoryScannerPlugin {
|
||||||
volume_repo: Arc<dyn StorageVolumeRepository>,
|
volume_repo: Arc<dyn StorageVolumeRepository>,
|
||||||
path_repo: Arc<dyn LibraryPathRepository>,
|
path_repo: Arc<dyn LibraryPathRepository>,
|
||||||
file_storage: Arc<dyn FileStoragePort>,
|
|
||||||
register_handler: Arc<RegisterAssetHandler>,
|
register_handler: Arc<RegisterAssetHandler>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,13 +21,11 @@ impl DirectoryScannerPlugin {
|
|||||||
pub fn new(
|
pub fn new(
|
||||||
volume_repo: Arc<dyn StorageVolumeRepository>,
|
volume_repo: Arc<dyn StorageVolumeRepository>,
|
||||||
path_repo: Arc<dyn LibraryPathRepository>,
|
path_repo: Arc<dyn LibraryPathRepository>,
|
||||||
file_storage: Arc<dyn FileStoragePort>,
|
|
||||||
register_handler: Arc<RegisterAssetHandler>,
|
register_handler: Arc<RegisterAssetHandler>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
volume_repo,
|
volume_repo,
|
||||||
path_repo,
|
path_repo,
|
||||||
file_storage,
|
|
||||||
register_handler,
|
register_handler,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,7 +53,7 @@ fn classify(filename: &str) -> Option<(AssetType, &'static str)> {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl PluginExecutor for DirectoryScannerPlugin {
|
impl PluginExecutor for DirectoryScannerPlugin {
|
||||||
fn plugin_name(&self) -> &str {
|
fn plugin_name(&self) -> &str {
|
||||||
"directory_scanner"
|
"scan_directory"
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(
|
async fn execute(
|
||||||
@@ -92,8 +90,14 @@ impl PluginExecutor for DirectoryScannerPlugin {
|
|||||||
DomainError::Validation(format!("LibraryPath {} has no designated owner", path_id))
|
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;
|
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 found = 0u64;
|
||||||
let mut registered = 0u64;
|
let mut registered = 0u64;
|
||||||
@@ -101,29 +105,40 @@ impl PluginExecutor for DirectoryScannerPlugin {
|
|||||||
let mut dirs_to_scan = vec![scan_root.to_string()];
|
let mut dirs_to_scan = vec![scan_root.to_string()];
|
||||||
|
|
||||||
while let Some(dir) = dirs_to_scan.pop() {
|
while let Some(dir) = dirs_to_scan.pop() {
|
||||||
let entries = match self.file_storage.list_directory(&dir).await {
|
let abs_dir = if dir.is_empty() {
|
||||||
Ok(e) => e,
|
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) => {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
for entry in entries {
|
while let Ok(Some(entry)) = read_dir.next_entry().await {
|
||||||
let full_path = if dir.is_empty() {
|
let meta = match entry.metadata().await {
|
||||||
entry.path.clone()
|
Ok(m) => m,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
let relative = if dir.is_empty() {
|
||||||
|
name.clone()
|
||||||
} else {
|
} else {
|
||||||
format!("{}/{}", dir, entry.path)
|
format!("{}/{}", dir, name)
|
||||||
};
|
};
|
||||||
|
|
||||||
if entry.is_directory {
|
if meta.is_dir() {
|
||||||
dirs_to_scan.push(full_path);
|
dirs_to_scan.push(relative);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
found += 1;
|
found += 1;
|
||||||
|
|
||||||
let (asset_type, mime_type) = match classify(&entry.path) {
|
let (asset_type, mime_type) = match classify(&name) {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => {
|
None => {
|
||||||
skipped += 1;
|
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,
|
Ok(d) => d,
|
||||||
Err(e) => {
|
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;
|
skipped += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -144,7 +160,7 @@ impl PluginExecutor for DirectoryScannerPlugin {
|
|||||||
|
|
||||||
let cmd = RegisterAssetCommand {
|
let cmd = RegisterAssetCommand {
|
||||||
volume_id: library_path.volume_id,
|
volume_id: library_path.volume_id,
|
||||||
relative_path: full_path.clone(),
|
relative_path: relative.clone(),
|
||||||
checksum,
|
checksum,
|
||||||
asset_type,
|
asset_type,
|
||||||
mime_type: mime_type.to_string(),
|
mime_type: mime_type.to_string(),
|
||||||
@@ -156,11 +172,11 @@ impl PluginExecutor for DirectoryScannerPlugin {
|
|||||||
Ok((asset, dup)) => {
|
Ok((asset, dup)) => {
|
||||||
registered += 1;
|
registered += 1;
|
||||||
if dup.is_some() {
|
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) => {
|
Err(e) => {
|
||||||
warn!(path = full_path, error = %e, "failed to register asset");
|
warn!(path = relative, error = %e, "failed to register asset");
|
||||||
skipped += 1;
|
skipped += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ use domain::{
|
|||||||
entities::{AssetMetadata, MetadataSource},
|
entities::{AssetMetadata, MetadataSource},
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
ports::{
|
ports::{
|
||||||
AssetMetadataRepository, AssetRepository, FileStoragePort, MetadataExtractorPort,
|
AssetMetadataRepository, AssetRepository, MetadataExtractorPort, PluginExecutor,
|
||||||
PluginExecutor,
|
VolumeFileResolver,
|
||||||
},
|
},
|
||||||
value_objects::{MetadataValue, StructuredData, SystemId},
|
value_objects::{MetadataValue, StructuredData, SystemId},
|
||||||
};
|
};
|
||||||
@@ -13,7 +13,7 @@ use tracing::info;
|
|||||||
|
|
||||||
pub struct MetadataExtractorPlugin {
|
pub struct MetadataExtractorPlugin {
|
||||||
asset_repo: Arc<dyn AssetRepository>,
|
asset_repo: Arc<dyn AssetRepository>,
|
||||||
file_storage: Arc<dyn FileStoragePort>,
|
volume_resolver: Arc<dyn VolumeFileResolver>,
|
||||||
metadata_repo: Arc<dyn AssetMetadataRepository>,
|
metadata_repo: Arc<dyn AssetMetadataRepository>,
|
||||||
extractor: Arc<dyn MetadataExtractorPort>,
|
extractor: Arc<dyn MetadataExtractorPort>,
|
||||||
}
|
}
|
||||||
@@ -21,13 +21,13 @@ pub struct MetadataExtractorPlugin {
|
|||||||
impl MetadataExtractorPlugin {
|
impl MetadataExtractorPlugin {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
asset_repo: Arc<dyn AssetRepository>,
|
asset_repo: Arc<dyn AssetRepository>,
|
||||||
file_storage: Arc<dyn FileStoragePort>,
|
volume_resolver: Arc<dyn VolumeFileResolver>,
|
||||||
metadata_repo: Arc<dyn AssetMetadataRepository>,
|
metadata_repo: Arc<dyn AssetMetadataRepository>,
|
||||||
extractor: Arc<dyn MetadataExtractorPort>,
|
extractor: Arc<dyn MetadataExtractorPort>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
asset_repo,
|
asset_repo,
|
||||||
file_storage,
|
volume_resolver,
|
||||||
metadata_repo,
|
metadata_repo,
|
||||||
extractor,
|
extractor,
|
||||||
}
|
}
|
||||||
@@ -56,8 +56,13 @@ impl PluginExecutor for MetadataExtractorPlugin {
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| DomainError::NotFound(format!("Asset {} not found", asset_id)))?;
|
.ok_or_else(|| DomainError::NotFound(format!("Asset {} not found", asset_id)))?;
|
||||||
|
|
||||||
let path = &asset.source_reference.relative_path;
|
let data = self
|
||||||
let data = self.file_storage.read_file(path).await?;
|
.volume_resolver
|
||||||
|
.read_by_volume(
|
||||||
|
&asset.source_reference.volume_id,
|
||||||
|
&asset.source_reference.relative_path,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let mut extracted = self.extractor.extract(&data)?;
|
let mut extracted = self.extractor.extract(&data)?;
|
||||||
extracted.insert("file_size_bytes", MetadataValue::Integer(data.len() as i64));
|
extracted.insert("file_size_bytes", MetadataValue::Integer(data.len() as i64));
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use domain::{
|
|||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
ports::{
|
ports::{
|
||||||
AssetRepository, DerivativeRepository, FileStoragePort, PluginExecutor,
|
AssetRepository, DerivativeRepository, FileStoragePort, PluginExecutor,
|
||||||
ThumbnailGeneratorPort,
|
ThumbnailGeneratorPort, VolumeFileResolver,
|
||||||
},
|
},
|
||||||
value_objects::{MetadataValue, StructuredData, SystemId},
|
value_objects::{MetadataValue, StructuredData, SystemId},
|
||||||
};
|
};
|
||||||
@@ -13,6 +13,7 @@ use tracing::info;
|
|||||||
|
|
||||||
pub struct ThumbnailGeneratorPlugin {
|
pub struct ThumbnailGeneratorPlugin {
|
||||||
asset_repo: Arc<dyn AssetRepository>,
|
asset_repo: Arc<dyn AssetRepository>,
|
||||||
|
volume_resolver: Arc<dyn VolumeFileResolver>,
|
||||||
file_storage: Arc<dyn FileStoragePort>,
|
file_storage: Arc<dyn FileStoragePort>,
|
||||||
derivative_repo: Arc<dyn DerivativeRepository>,
|
derivative_repo: Arc<dyn DerivativeRepository>,
|
||||||
thumbnail_gen: Arc<dyn ThumbnailGeneratorPort>,
|
thumbnail_gen: Arc<dyn ThumbnailGeneratorPort>,
|
||||||
@@ -21,12 +22,14 @@ pub struct ThumbnailGeneratorPlugin {
|
|||||||
impl ThumbnailGeneratorPlugin {
|
impl ThumbnailGeneratorPlugin {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
asset_repo: Arc<dyn AssetRepository>,
|
asset_repo: Arc<dyn AssetRepository>,
|
||||||
|
volume_resolver: Arc<dyn VolumeFileResolver>,
|
||||||
file_storage: Arc<dyn FileStoragePort>,
|
file_storage: Arc<dyn FileStoragePort>,
|
||||||
derivative_repo: Arc<dyn DerivativeRepository>,
|
derivative_repo: Arc<dyn DerivativeRepository>,
|
||||||
thumbnail_gen: Arc<dyn ThumbnailGeneratorPort>,
|
thumbnail_gen: Arc<dyn ThumbnailGeneratorPort>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
asset_repo,
|
asset_repo,
|
||||||
|
volume_resolver,
|
||||||
file_storage,
|
file_storage,
|
||||||
derivative_repo,
|
derivative_repo,
|
||||||
thumbnail_gen,
|
thumbnail_gen,
|
||||||
@@ -92,8 +95,11 @@ impl PluginExecutor for ThumbnailGeneratorPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let source_bytes = self
|
let source_bytes = self
|
||||||
.file_storage
|
.volume_resolver
|
||||||
.read_file(&asset.source_reference.relative_path)
|
.read_by_volume(
|
||||||
|
&asset.source_reference.volume_id,
|
||||||
|
&asset.source_reference.relative_path,
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let output = self
|
let output = self
|
||||||
|
|||||||
131
k-photos-frontend/app/(app)/admin/duplicates/page.tsx
Normal file
131
k-photos-frontend/app/(app)/admin/duplicates/page.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useDuplicates, useResolveDuplicate } from "@/hooks/use-duplicates"
|
||||||
|
import { getTokens } from "@/lib/auth"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
function AssetThumb({ assetId }: { assetId: string }) {
|
||||||
|
const [src, setSrc] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let revoke: string | null = null
|
||||||
|
const { access } = getTokens()
|
||||||
|
const headers: HeadersInit = access ? { Authorization: `Bearer ${access}` } : {}
|
||||||
|
fetch(`/api/v1/assets/${assetId}/derivatives/thumbnail_square`, { headers })
|
||||||
|
.then((r) => (r.ok ? r.blob() : Promise.reject()))
|
||||||
|
.catch(() =>
|
||||||
|
fetch(`/api/v1/assets/${assetId}/file`, { headers }).then((r) =>
|
||||||
|
r.ok ? r.blob() : Promise.reject(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then((blob) => {
|
||||||
|
revoke = URL.createObjectURL(blob)
|
||||||
|
setSrc(revoke)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
return () => {
|
||||||
|
if (revoke) URL.revokeObjectURL(revoke)
|
||||||
|
}
|
||||||
|
}, [assetId])
|
||||||
|
|
||||||
|
return src ? (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt=""
|
||||||
|
className="h-20 w-20 shrink-0 rounded object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Skeleton className="h-20 w-20 shrink-0 rounded" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DuplicatesPage() {
|
||||||
|
const { data: groups, isLoading } = useDuplicates()
|
||||||
|
const resolve = useResolveDuplicate()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h1 className="text-lg font-semibold">Duplicate Resolution</h1>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (groups ?? []).length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No duplicate groups found.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
(groups ?? []).map((group) => (
|
||||||
|
<Card key={group.group_id}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="font-mono">
|
||||||
|
{group.group_id.slice(0, 8)}...
|
||||||
|
</span>
|
||||||
|
<Badge variant="secondary">{group.detection_method}</Badge>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
group.status === "Pending" ? "default" : "secondary"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{group.status}
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{group.candidates.map((c) => (
|
||||||
|
<div
|
||||||
|
key={c.asset_id}
|
||||||
|
className="flex gap-3 rounded border p-2"
|
||||||
|
>
|
||||||
|
<AssetThumb assetId={c.asset_id} />
|
||||||
|
<div className="flex flex-1 flex-col justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-xs">
|
||||||
|
{c.asset_id.slice(0, 12)}...
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{(c.similarity_score * 100).toFixed(1)}% match
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-6 self-start text-xs"
|
||||||
|
disabled={resolve.isPending}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await resolve.mutateAsync({
|
||||||
|
groupId: group.group_id,
|
||||||
|
keepAssetId: c.asset_id,
|
||||||
|
})
|
||||||
|
toast.success("Resolved — kept this asset")
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to resolve")
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Keep
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
275
k-photos-frontend/app/(app)/admin/jobs/page.tsx
Normal file
275
k-photos-frontend/app/(app)/admin/jobs/page.tsx
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import {
|
||||||
|
useJobs,
|
||||||
|
useStartJob,
|
||||||
|
useFailJob,
|
||||||
|
useCompleteJob,
|
||||||
|
JOBS_PAGE_SIZE,
|
||||||
|
} from "@/hooks/use-jobs"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import {
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
PlayIcon,
|
||||||
|
CheckIcon,
|
||||||
|
XIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
const STATUS_FILTERS = [
|
||||||
|
{ value: undefined, label: "All" },
|
||||||
|
{ value: "queued", label: "Queued" },
|
||||||
|
{ value: "running", label: "Running" },
|
||||||
|
{ value: "completed", label: "Completed" },
|
||||||
|
{ value: "failed", label: "Failed" },
|
||||||
|
]
|
||||||
|
|
||||||
|
function statusVariant(status: string) {
|
||||||
|
switch (status.toLowerCase()) {
|
||||||
|
case "queued":
|
||||||
|
return "secondary" as const
|
||||||
|
case "running":
|
||||||
|
return "default" as const
|
||||||
|
case "completed":
|
||||||
|
return "default" as const
|
||||||
|
case "failed":
|
||||||
|
return "destructive" as const
|
||||||
|
default:
|
||||||
|
return "secondary" as const
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JobsPage() {
|
||||||
|
const [filter, setFilter] = useState<string | undefined>(undefined)
|
||||||
|
const [offset, setOffset] = useState(0)
|
||||||
|
const jobs = useJobs(filter, offset)
|
||||||
|
const startJob = useStartJob()
|
||||||
|
const failJob = useFailJob()
|
||||||
|
const completeJob = useCompleteJob()
|
||||||
|
|
||||||
|
const total = jobs.data?.total ?? 0
|
||||||
|
const page = Math.floor(offset / JOBS_PAGE_SIZE) + 1
|
||||||
|
const totalPages = Math.ceil(total / JOBS_PAGE_SIZE)
|
||||||
|
|
||||||
|
const handleFilterChange = (v: string) => {
|
||||||
|
setFilter(v === "all" ? undefined : v)
|
||||||
|
setOffset(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-lg font-semibold">Job Queue</h1>
|
||||||
|
{total > 0 && (
|
||||||
|
<span className="text-sm text-muted-foreground">{total} total</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs value={filter ?? "all"} onValueChange={handleFilterChange}>
|
||||||
|
<TabsList>
|
||||||
|
{STATUS_FILTERS.map((f) => (
|
||||||
|
<TabsTrigger key={f.label} value={f.value ?? "all"}>
|
||||||
|
{f.label}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Jobs</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{jobs.isLoading ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-6" />
|
||||||
|
<TableHead>ID</TableHead>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Priority</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead>Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{(jobs.data?.jobs ?? []).map((job) => (
|
||||||
|
<Collapsible key={job.job_id} asChild>
|
||||||
|
<>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="p-0 pl-2">
|
||||||
|
{job.error_message && (
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5"
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="h-3 w-3 transition-transform [[data-state=open]>&]:rotate-180" />
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{job.job_id.slice(0, 8)}...
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{job.job_type}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={statusVariant(job.status)}>
|
||||||
|
{job.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{job.priority}</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
{new Date(job.created_at).toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{job.status.toLowerCase() === "queued" && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className="h-6 w-6"
|
||||||
|
title="Start"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await startJob.mutateAsync(job.job_id)
|
||||||
|
toast.success("Job started")
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to start")
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlayIcon className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{job.status.toLowerCase() === "running" && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className="h-6 w-6"
|
||||||
|
title="Complete"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await completeJob.mutateAsync({
|
||||||
|
jobId: job.job_id,
|
||||||
|
result: {},
|
||||||
|
})
|
||||||
|
toast.success("Job completed")
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed")
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckIcon className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="destructive"
|
||||||
|
className="h-6 w-6"
|
||||||
|
title="Fail"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await failJob.mutateAsync({
|
||||||
|
jobId: job.job_id,
|
||||||
|
error: "Manually failed",
|
||||||
|
})
|
||||||
|
toast.success("Job failed")
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed")
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XIcon className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{job.error_message && (
|
||||||
|
<CollapsibleContent asChild>
|
||||||
|
<tr>
|
||||||
|
<td />
|
||||||
|
<td colSpan={6} className="pb-3 pt-0">
|
||||||
|
<pre className="mt-1 max-h-40 overflow-auto rounded bg-destructive/10 p-2 text-xs text-destructive">
|
||||||
|
{job.error_message}
|
||||||
|
</pre>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</CollapsibleContent>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
</Collapsible>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between border-t pt-3">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Page {page} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 w-7"
|
||||||
|
disabled={offset === 0}
|
||||||
|
onClick={() =>
|
||||||
|
setOffset(Math.max(0, offset - JOBS_PAGE_SIZE))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 w-7"
|
||||||
|
disabled={offset + JOBS_PAGE_SIZE >= total}
|
||||||
|
onClick={() => setOffset(offset + JOBS_PAGE_SIZE)}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
k-photos-frontend/app/(app)/admin/layout.tsx
Normal file
22
k-photos-frontend/app/(app)/admin/layout.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useAuth } from "@/hooks/use-auth"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useEffect } from "react"
|
||||||
|
|
||||||
|
export default function AdminLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const { isAdmin, isLoading } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && !isAdmin) router.replace("/")
|
||||||
|
}, [isLoading, isAdmin, router])
|
||||||
|
|
||||||
|
if (isLoading || !isAdmin) return null
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
128
k-photos-frontend/app/(app)/admin/pipelines/page.tsx
Normal file
128
k-photos-frontend/app/(app)/admin/pipelines/page.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { usePipelines, useConfigurePipeline } from "@/hooks/use-pipelines"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
export default function PipelinesPage() {
|
||||||
|
const { data: pipelines, isLoading } = usePipelines()
|
||||||
|
const configure = useConfigurePipeline()
|
||||||
|
const [triggerEvent, setTriggerEvent] = useState("")
|
||||||
|
const [stepsJson, setStepsJson] = useState("[]")
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
try {
|
||||||
|
const steps = JSON.parse(stepsJson)
|
||||||
|
await configure.mutateAsync({ trigger_event: triggerEvent, steps })
|
||||||
|
toast.success("Pipeline configured")
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed — check JSON syntax")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<h1 className="text-lg font-semibold">Pipeline Configuration</h1>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Pipelines</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Trigger Event</TableHead>
|
||||||
|
<TableHead>Steps</TableHead>
|
||||||
|
<TableHead>ID</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{(pipelines ?? []).map((p) => (
|
||||||
|
<TableRow key={p.pipeline_id}>
|
||||||
|
<TableCell className="font-mono text-sm">
|
||||||
|
{p.trigger_event}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary">{p.steps_count}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{p.pipeline_id.slice(0, 8)}...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Configure Pipeline</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Define a processing pipeline triggered by an event
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label className="text-xs">Trigger Event</Label>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
value={triggerEvent}
|
||||||
|
onChange={(e) => setTriggerEvent(e.target.value)}
|
||||||
|
placeholder="asset.ingested"
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label className="text-xs">
|
||||||
|
Steps (JSON array of {`{ plugin_id, config }`})
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
value={stepsJson}
|
||||||
|
onChange={(e) => setStepsJson(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
className="self-start"
|
||||||
|
disabled={configure.isPending}
|
||||||
|
>
|
||||||
|
Save Pipeline
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
141
k-photos-frontend/app/(app)/admin/plugins/page.tsx
Normal file
141
k-photos-frontend/app/(app)/admin/plugins/page.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { usePlugins, useManagePlugin } from "@/hooks/use-plugins"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
export default function PluginsPage() {
|
||||||
|
const { data: plugins, isLoading } = usePlugins()
|
||||||
|
const manage = useManagePlugin()
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [pluginType, setPluginType] = useState("media_processor")
|
||||||
|
|
||||||
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
try {
|
||||||
|
await manage.mutateAsync({
|
||||||
|
action: "create",
|
||||||
|
name,
|
||||||
|
plugin_type: pluginType,
|
||||||
|
})
|
||||||
|
setName("")
|
||||||
|
toast.success("Plugin created")
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to create plugin")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggle = async (pluginId: string, enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
await manage.mutateAsync({
|
||||||
|
action: enabled ? "enable" : "disable",
|
||||||
|
plugin_id: pluginId,
|
||||||
|
})
|
||||||
|
toast.success(enabled ? "Plugin enabled" : "Plugin disabled")
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to update plugin")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<h1 className="text-lg font-semibold">Plugin Management</h1>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Installed Plugins</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
<TableHead>Enabled</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{(plugins ?? []).map((p) => (
|
||||||
|
<TableRow key={p.plugin_id}>
|
||||||
|
<TableCell className="font-mono text-sm">
|
||||||
|
{p.name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary">{p.plugin_type}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Switch
|
||||||
|
checked={p.is_enabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleToggle(p.plugin_id, checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Create Plugin</CardTitle>
|
||||||
|
<CardDescription>Register a new processing plugin</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleCreate} className="flex items-end gap-2">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label className="text-xs">Name</Label>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="my-processor"
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label className="text-xs">Type</Label>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
value={pluginType}
|
||||||
|
onChange={(e) => setPluginType(e.target.value)}
|
||||||
|
placeholder="media_processor"
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" size="sm" disabled={manage.isPending}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
178
k-photos-frontend/app/(app)/admin/sidecars/page.tsx
Normal file
178
k-photos-frontend/app/(app)/admin/sidecars/page.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import {
|
||||||
|
useDetectChanges,
|
||||||
|
useFullExport,
|
||||||
|
useFullImport,
|
||||||
|
useExportSidecar,
|
||||||
|
useImportSidecar,
|
||||||
|
useResolveSidecarConflict,
|
||||||
|
} from "@/hooks/use-sidecars"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
export default function SidecarsPage() {
|
||||||
|
const detectChanges = useDetectChanges()
|
||||||
|
const fullExport = useFullExport()
|
||||||
|
const fullImport = useFullImport()
|
||||||
|
const exportSidecar = useExportSidecar()
|
||||||
|
const importSidecar = useImportSidecar()
|
||||||
|
const resolveConflict = useResolveSidecarConflict()
|
||||||
|
|
||||||
|
const [assetId, setAssetId] = useState("")
|
||||||
|
const [conflictPolicy, setConflictPolicy] = useState("keep_local")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<h1 className="text-lg font-semibold">Sidecar Management</h1>
|
||||||
|
|
||||||
|
{/* Bulk actions */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Bulk Operations</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Manage sidecar metadata across all assets
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={detectChanges.isPending}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const res = await detectChanges.mutateAsync()
|
||||||
|
toast.success(`Detected ${res.changed_count} change(s)`)
|
||||||
|
} catch {
|
||||||
|
toast.error("Detection failed")
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Detect Changes
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={fullExport.isPending}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await fullExport.mutateAsync()
|
||||||
|
toast.success("Full export started")
|
||||||
|
} catch {
|
||||||
|
toast.error("Export failed")
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Full Export
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={fullImport.isPending}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await fullImport.mutateAsync()
|
||||||
|
toast.success("Full import started")
|
||||||
|
} catch {
|
||||||
|
toast.error("Import failed")
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Full Import
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Per-asset */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Per-Asset Operations</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label className="text-xs">Asset ID</Label>
|
||||||
|
<Input
|
||||||
|
value={assetId}
|
||||||
|
onChange={(e) => setAssetId(e.target.value)}
|
||||||
|
placeholder="uuid"
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!assetId || exportSidecar.isPending}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const res = await exportSidecar.mutateAsync(assetId)
|
||||||
|
toast.success(
|
||||||
|
<>
|
||||||
|
Exported: <Badge variant="secondary">{res.status}</Badge>
|
||||||
|
</>,
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
toast.error("Export failed")
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!assetId || importSidecar.isPending}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const res = await importSidecar.mutateAsync(assetId)
|
||||||
|
toast.success(`Import: ${res.status}`)
|
||||||
|
} catch {
|
||||||
|
toast.error("Import failed")
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Input
|
||||||
|
value={conflictPolicy}
|
||||||
|
onChange={(e) => setConflictPolicy(e.target.value)}
|
||||||
|
placeholder="keep_local"
|
||||||
|
className="h-8 w-32"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={!assetId || resolveConflict.isPending}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await resolveConflict.mutateAsync({
|
||||||
|
assetId,
|
||||||
|
policy: conflictPolicy,
|
||||||
|
})
|
||||||
|
toast.success("Conflict resolved")
|
||||||
|
} catch {
|
||||||
|
toast.error("Resolve failed")
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Resolve
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
384
k-photos-frontend/app/(app)/admin/storage/page.tsx
Normal file
384
k-photos-frontend/app/(app)/admin/storage/page.tsx
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import {
|
||||||
|
useVolumes,
|
||||||
|
useRegisterVolume,
|
||||||
|
useDeleteVolume,
|
||||||
|
useLibraryPaths,
|
||||||
|
useRegisterLibraryPath,
|
||||||
|
useDeleteLibraryPath,
|
||||||
|
} from "@/hooks/use-storage-admin"
|
||||||
|
import { useEnqueueJob } from "@/hooks/use-jobs"
|
||||||
|
import { useAuth } from "@/hooks/use-auth"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { FolderSyncIcon, Trash2Icon } from "lucide-react"
|
||||||
|
|
||||||
|
export default function StoragePage() {
|
||||||
|
const volumes = useVolumes()
|
||||||
|
const paths = useLibraryPaths()
|
||||||
|
const registerVolume = useRegisterVolume()
|
||||||
|
const deleteVolume = useDeleteVolume()
|
||||||
|
const registerPath = useRegisterLibraryPath()
|
||||||
|
const deletePath = useDeleteLibraryPath()
|
||||||
|
const enqueueJob = useEnqueueJob()
|
||||||
|
const { user } = useAuth()
|
||||||
|
|
||||||
|
const [volName, setVolName] = useState("")
|
||||||
|
const [volUri, setVolUri] = useState("")
|
||||||
|
const [volWritable, setVolWritable] = useState(true)
|
||||||
|
|
||||||
|
const [pathVolumeId, setPathVolumeId] = useState("")
|
||||||
|
const [pathRelative, setPathRelative] = useState("")
|
||||||
|
const [pathIngest, setPathIngest] = useState(true)
|
||||||
|
|
||||||
|
const [importUri, setImportUri] = useState("")
|
||||||
|
const [importName, setImportName] = useState("")
|
||||||
|
const [importing, setImporting] = useState(false)
|
||||||
|
|
||||||
|
const handleCreateVolume = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
try {
|
||||||
|
await registerVolume.mutateAsync({
|
||||||
|
volume_name: volName,
|
||||||
|
uri_prefix: volUri,
|
||||||
|
is_writable: volWritable,
|
||||||
|
})
|
||||||
|
setVolName("")
|
||||||
|
setVolUri("")
|
||||||
|
toast.success("Volume registered")
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to register volume")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreatePath = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!user) return
|
||||||
|
try {
|
||||||
|
await registerPath.mutateAsync({
|
||||||
|
volume_id: pathVolumeId,
|
||||||
|
relative_path: pathRelative,
|
||||||
|
owner_id: user.id,
|
||||||
|
is_ingest_destination: pathIngest,
|
||||||
|
})
|
||||||
|
setPathRelative("")
|
||||||
|
toast.success("Library path registered")
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to register library path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImportLibrary = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!user || !importUri) return
|
||||||
|
setImporting(true)
|
||||||
|
try {
|
||||||
|
const vol = await registerVolume.mutateAsync({
|
||||||
|
volume_name: importName || "imported",
|
||||||
|
uri_prefix: importUri,
|
||||||
|
is_writable: false,
|
||||||
|
})
|
||||||
|
const path = await registerPath.mutateAsync({
|
||||||
|
volume_id: vol.id,
|
||||||
|
relative_path: "",
|
||||||
|
owner_id: user.id,
|
||||||
|
is_ingest_destination: false,
|
||||||
|
})
|
||||||
|
await enqueueJob.mutateAsync({
|
||||||
|
job_type: "scan_directory",
|
||||||
|
payload: { library_path_id: path.id },
|
||||||
|
})
|
||||||
|
setImportUri("")
|
||||||
|
setImportName("")
|
||||||
|
toast.success("Import started — check Jobs page for progress")
|
||||||
|
} catch {
|
||||||
|
toast.error("Import failed")
|
||||||
|
} finally {
|
||||||
|
setImporting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const volumeList = volumes.data ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<h1 className="text-lg font-semibold">Storage Management</h1>
|
||||||
|
|
||||||
|
{/* Import Library */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FolderSyncIcon className="h-4 w-4" />
|
||||||
|
Import Library
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Point to an existing photo directory — registers a volume, library
|
||||||
|
path, and starts scanning in one step
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form
|
||||||
|
onSubmit={handleImportLibrary}
|
||||||
|
className="flex items-end gap-2"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label className="text-xs">Name</Label>
|
||||||
|
<Input
|
||||||
|
value={importName}
|
||||||
|
onChange={(e) => setImportName(e.target.value)}
|
||||||
|
placeholder="family-photos"
|
||||||
|
className="h-8 w-40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col gap-1">
|
||||||
|
<Label className="text-xs">Directory Path</Label>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
value={importUri}
|
||||||
|
onChange={(e) => setImportUri(e.target.value)}
|
||||||
|
placeholder="file:///mnt/nas/photos"
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" size="sm" disabled={importing}>
|
||||||
|
{importing ? "Importing..." : "Import"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Volumes */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Volumes</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
{volumes.isLoading ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>URI Prefix</TableHead>
|
||||||
|
<TableHead>Writable</TableHead>
|
||||||
|
<TableHead className="w-10" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{volumeList.map((v) => (
|
||||||
|
<TableRow key={v.id}>
|
||||||
|
<TableCell className="font-mono text-sm">
|
||||||
|
{v.volume_name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">
|
||||||
|
{v.uri_prefix}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={v.is_writable ? "default" : "secondary"}>
|
||||||
|
{v.is_writable ? "Yes" : "No"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await deleteVolume.mutateAsync(v.id)
|
||||||
|
toast.success("Volume deleted")
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to delete volume")
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2Icon className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleCreateVolume} className="flex items-end gap-2">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label className="text-xs">Name</Label>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
value={volName}
|
||||||
|
onChange={(e) => setVolName(e.target.value)}
|
||||||
|
placeholder="local"
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label className="text-xs">URI Prefix</Label>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
value={volUri}
|
||||||
|
onChange={(e) => setVolUri(e.target.value)}
|
||||||
|
placeholder="file:///data/media"
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 pb-1">
|
||||||
|
<Checkbox
|
||||||
|
id="vol-writable"
|
||||||
|
checked={volWritable}
|
||||||
|
onCheckedChange={(c) => setVolWritable(c === true)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="vol-writable" className="text-xs">
|
||||||
|
Writable
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" size="sm" disabled={registerVolume.isPending}>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Library Paths */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Library Paths</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
{paths.isLoading ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Volume</TableHead>
|
||||||
|
<TableHead>Path</TableHead>
|
||||||
|
<TableHead>Ingest Dest</TableHead>
|
||||||
|
<TableHead className="w-10" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{(paths.data ?? []).map((p) => {
|
||||||
|
const vol = volumeList.find((v) => v.id === p.volume_id)
|
||||||
|
return (
|
||||||
|
<TableRow key={p.id}>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{vol?.volume_name ?? p.volume_id.slice(0, 8) + "..."}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">
|
||||||
|
{p.relative_path || "(root)"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
p.is_ingest_destination ? "default" : "secondary"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{p.is_ingest_destination ? "Yes" : "No"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await deletePath.mutateAsync(p.id)
|
||||||
|
toast.success("Library path deleted")
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to delete path")
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2Icon className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleCreatePath} className="flex items-end gap-2">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label className="text-xs">Volume</Label>
|
||||||
|
<Select value={pathVolumeId} onValueChange={setPathVolumeId}>
|
||||||
|
<SelectTrigger className="h-8 w-44">
|
||||||
|
<SelectValue placeholder="Select volume" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{volumeList.map((v) => (
|
||||||
|
<SelectItem key={v.id} value={v.id}>
|
||||||
|
{v.volume_name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label className="text-xs">Relative Path</Label>
|
||||||
|
<Input
|
||||||
|
value={pathRelative}
|
||||||
|
onChange={(e) => setPathRelative(e.target.value)}
|
||||||
|
placeholder="(empty = root)"
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 pb-1">
|
||||||
|
<Checkbox
|
||||||
|
id="path-ingest"
|
||||||
|
checked={pathIngest}
|
||||||
|
onCheckedChange={(c) => setPathIngest(c === true)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="path-ingest" className="text-xs">
|
||||||
|
Ingest
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
disabled={!pathVolumeId || registerPath.isPending}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
129
k-photos-frontend/app/(app)/albums/[id]/page.tsx
Normal file
129
k-photos-frontend/app/(app)/albums/[id]/page.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import { useParams } from "next/navigation"
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import api from "@/lib/api"
|
||||||
|
import { useAlbums } from "@/hooks/use-albums"
|
||||||
|
import { groupByDate } from "@/lib/timeline"
|
||||||
|
import type { AlbumResponse, AssetResponse } from "@/lib/types"
|
||||||
|
import { PhotoGrid } from "@/components/photo-grid"
|
||||||
|
import { AssetPickerDialog } from "@/components/asset-picker-dialog"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { PlusIcon } from "lucide-react"
|
||||||
|
|
||||||
|
export default function AlbumDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const { addEntry, removeEntry } = useAlbums()
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false)
|
||||||
|
|
||||||
|
const { data: album, isLoading: albumLoading } = useQuery({
|
||||||
|
queryKey: ["album", id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get<AlbumResponse>(`/albums/${id}`)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: assets, isLoading: assetsLoading } = useQuery({
|
||||||
|
queryKey: ["album", id, "assets"],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!album || album.asset_ids.length === 0) return []
|
||||||
|
const results = await Promise.all(
|
||||||
|
album.asset_ids.map((assetId) =>
|
||||||
|
api
|
||||||
|
.get<AssetResponse>(`/assets/${assetId}`)
|
||||||
|
.then((r) => r.data)
|
||||||
|
.catch(() => null),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return results.filter(Boolean) as AssetResponse[]
|
||||||
|
},
|
||||||
|
enabled: !!album,
|
||||||
|
})
|
||||||
|
|
||||||
|
const groups = useMemo(() => groupByDate(assets ?? []), [assets])
|
||||||
|
|
||||||
|
const existingIds = useMemo(
|
||||||
|
() => new Set(album?.asset_ids ?? []),
|
||||||
|
[album],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleRemove = async (assetId: string) => {
|
||||||
|
try {
|
||||||
|
await removeEntry({ albumId: id, assetId })
|
||||||
|
qc.invalidateQueries({ queryKey: ["album", id] })
|
||||||
|
toast.success("Removed from album")
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to remove")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddPhotos = async (assetIds: string[]) => {
|
||||||
|
let added = 0
|
||||||
|
for (const assetId of assetIds) {
|
||||||
|
try {
|
||||||
|
await addEntry({ albumId: id, assetId })
|
||||||
|
added++
|
||||||
|
} catch {
|
||||||
|
/* skip duplicates */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
qc.invalidateQueries({ queryKey: ["album", id] })
|
||||||
|
toast.success(`Added ${added} photo(s)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (albumLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!album) {
|
||||||
|
return (
|
||||||
|
<div className="py-12 text-center text-muted-foreground">
|
||||||
|
Album not found
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold">{album.title}</h1>
|
||||||
|
{album.description && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{album.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{album.asset_count} photos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={() => setPickerOpen(true)}>
|
||||||
|
<PlusIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Add Photos
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<PhotoGrid
|
||||||
|
groups={groups}
|
||||||
|
isLoading={assetsLoading}
|
||||||
|
hasMore={false}
|
||||||
|
onLoadMore={() => {}}
|
||||||
|
onRemoveAsset={handleRemove}
|
||||||
|
/>
|
||||||
|
<AssetPickerDialog
|
||||||
|
open={pickerOpen}
|
||||||
|
onOpenChange={setPickerOpen}
|
||||||
|
excludeIds={existingIds}
|
||||||
|
onConfirm={handleAddPhotos}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
76
k-photos-frontend/app/(app)/layout.tsx
Normal file
76
k-photos-frontend/app/(app)/layout.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useAuth } from "@/hooks/use-auth"
|
||||||
|
import {
|
||||||
|
SidebarProvider,
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarInset,
|
||||||
|
SidebarTrigger,
|
||||||
|
} from "@/components/ui/sidebar"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { AlbumSidebar } from "@/components/album-sidebar"
|
||||||
|
import { AdminSidebar } from "@/components/admin-sidebar"
|
||||||
|
import { UploadDialog } from "@/components/upload-dialog"
|
||||||
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
|
import { CameraIcon, LogOutIcon } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const { user, isAuthenticated, isLoading, logout } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && !isAuthenticated) {
|
||||||
|
router.replace("/login")
|
||||||
|
}
|
||||||
|
}, [isLoading, isAuthenticated, router])
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh items-center justify-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarProvider>
|
||||||
|
<Sidebar>
|
||||||
|
<SidebarHeader className="flex flex-row items-center gap-2 px-4 py-3">
|
||||||
|
<Link href="/" className="flex items-center gap-2 font-semibold">
|
||||||
|
<CameraIcon className="h-5 w-5" />
|
||||||
|
K-Photos
|
||||||
|
</Link>
|
||||||
|
</SidebarHeader>
|
||||||
|
<SidebarContent>
|
||||||
|
<AlbumSidebar />
|
||||||
|
<AdminSidebar />
|
||||||
|
</SidebarContent>
|
||||||
|
<div className="flex items-center justify-between border-t px-4 py-2">
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{user?.username}
|
||||||
|
</span>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={logout}>
|
||||||
|
<LogOutIcon className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Sidebar>
|
||||||
|
<SidebarInset>
|
||||||
|
<header className="flex h-12 items-center gap-2 border-b px-4">
|
||||||
|
<SidebarTrigger />
|
||||||
|
<Separator orientation="vertical" className="h-4" />
|
||||||
|
<div className="flex-1" />
|
||||||
|
<UploadDialog />
|
||||||
|
</header>
|
||||||
|
<main className="flex-1 p-4">{children}</main>
|
||||||
|
</SidebarInset>
|
||||||
|
</SidebarProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
35
k-photos-frontend/app/(app)/page.tsx
Normal file
35
k-photos-frontend/app/(app)/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import { useTimeline, useDateSummary } from "@/hooks/use-timeline"
|
||||||
|
import { groupByDate } from "@/lib/timeline"
|
||||||
|
import { PhotoGrid } from "@/components/photo-grid"
|
||||||
|
import { DateScrubber } from "@/components/date-scrubber"
|
||||||
|
|
||||||
|
export default function TimelinePage() {
|
||||||
|
const { assets, isLoading, hasMore, loadMore, total } = useTimeline()
|
||||||
|
const { data: dateSummary } = useDateSummary()
|
||||||
|
const groups = useMemo(() => groupByDate(assets), [assets])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-lg font-semibold">Timeline</h1>
|
||||||
|
{total > 0 && (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{total} photos
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<PhotoGrid
|
||||||
|
groups={groups}
|
||||||
|
isLoading={isLoading}
|
||||||
|
hasMore={hasMore}
|
||||||
|
onLoadMore={() => loadMore()}
|
||||||
|
/>
|
||||||
|
<DateScrubber dates={dateSummary ?? []} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
11
k-photos-frontend/app/(auth)/layout.tsx
Normal file
11
k-photos-frontend/app/(auth)/layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export default function AuthLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh items-center justify-center bg-background p-4">
|
||||||
|
<div className="w-full max-w-sm">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
86
k-photos-frontend/app/(auth)/login/page.tsx
Normal file
86
k-photos-frontend/app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useAuth } from "@/hooks/use-auth"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const { login } = useAuth()
|
||||||
|
const [email, setEmail] = useState("")
|
||||||
|
const [password, setPassword] = useState("")
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError("")
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await login(email, password)
|
||||||
|
router.push("/")
|
||||||
|
} catch {
|
||||||
|
setError("Invalid email or password")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Sign in</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Enter your email to sign in to K-Photos
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading ? "Signing in..." : "Sign in"}
|
||||||
|
</Button>
|
||||||
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<Link href="/register" className="underline hover:text-foreground">
|
||||||
|
Register
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
97
k-photos-frontend/app/(auth)/register/page.tsx
Normal file
97
k-photos-frontend/app/(auth)/register/page.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useAuth } from "@/hooks/use-auth"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const { register } = useAuth()
|
||||||
|
const [username, setUsername] = useState("")
|
||||||
|
const [email, setEmail] = useState("")
|
||||||
|
const [password, setPassword] = useState("")
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError("")
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await register(username, email, password)
|
||||||
|
router.push("/")
|
||||||
|
} catch {
|
||||||
|
setError("Registration failed. Try a different email or username.")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Create account</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Sign up to start managing your photos
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="username">Username</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
required
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading ? "Creating account..." : "Create account"}
|
||||||
|
</Button>
|
||||||
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<Link href="/login" className="underline hover:text-foreground">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import { Geist, Geist_Mono, Inter } from "next/font/google"
|
import { Geist_Mono, Inter } from "next/font/google"
|
||||||
|
|
||||||
import "./globals.css"
|
import "./globals.css"
|
||||||
import { ThemeProvider } from "@/components/theme-provider"
|
import { ThemeProvider } from "@/components/theme-provider"
|
||||||
import { cn } from "@/lib/utils";
|
import { AuthProvider } from "@/components/auth-provider"
|
||||||
|
import { QueryProvider } from "@/components/query-provider"
|
||||||
|
import { Toaster } from "@/components/ui/sonner"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const inter = Inter({subsets:['latin'],variable:'--font-sans'})
|
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" })
|
||||||
|
|
||||||
const fontMono = Geist_Mono({
|
const fontMono = Geist_Mono({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
@@ -20,10 +23,22 @@ export default function RootLayout({
|
|||||||
<html
|
<html
|
||||||
lang="en"
|
lang="en"
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
className={cn("antialiased", fontMono.variable, "font-sans", inter.variable)}
|
className={cn(
|
||||||
|
"antialiased",
|
||||||
|
fontMono.variable,
|
||||||
|
"font-sans",
|
||||||
|
inter.variable,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<body>
|
<body>
|
||||||
<ThemeProvider>{children}</ThemeProvider>
|
<ThemeProvider>
|
||||||
|
<QueryProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
{children}
|
||||||
|
<Toaster />
|
||||||
|
</AuthProvider>
|
||||||
|
</QueryProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import { Button } from "@/components/ui/button"
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-svh p-6">
|
|
||||||
<div className="flex max-w-md min-w-0 flex-col gap-4 text-sm leading-loose">
|
|
||||||
<div>
|
|
||||||
<h1 className="font-medium">Project ready!</h1>
|
|
||||||
<p>You may now add components and start building.</p>
|
|
||||||
<p>We've already added the button component for you.</p>
|
|
||||||
<Button className="mt-2">Button</Button>
|
|
||||||
</div>
|
|
||||||
<div className="font-mono text-xs text-muted-foreground">
|
|
||||||
(Press <kbd>d</kbd> to toggle dark mode)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"name": "next-app",
|
"name": "next-app",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.5.0",
|
"@base-ui/react": "^1.5.0",
|
||||||
|
"@tanstack/react-query": "^5.100.14",
|
||||||
"axios": "^1.16.1",
|
"axios": "^1.16.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
"react-day-picker": "^10.0.1",
|
"react-day-picker": "^10.0.1",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"react-resizable-panels": "^4.11.2",
|
"react-resizable-panels": "^4.11.2",
|
||||||
|
"react-zoom-pan-pinch": "^4.0.3",
|
||||||
"recharts": "3.8.0",
|
"recharts": "3.8.0",
|
||||||
"shadcn": "^4.9.0",
|
"shadcn": "^4.9.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
@@ -437,6 +439,10 @@
|
|||||||
|
|
||||||
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.0", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "postcss": "^8.5.10", "tailwindcss": "4.3.0" } }, "sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w=="],
|
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.0", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "postcss": "^8.5.10", "tailwindcss": "4.3.0" } }, "sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w=="],
|
||||||
|
|
||||||
|
"@tanstack/query-core": ["@tanstack/query-core@5.100.14", "", {}, "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew=="],
|
||||||
|
|
||||||
|
"@tanstack/react-query": ["@tanstack/react-query@5.100.14", "", { "dependencies": { "@tanstack/query-core": "5.100.14" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw=="],
|
||||||
|
|
||||||
"@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="],
|
"@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="],
|
||||||
|
|
||||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
|
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
|
||||||
@@ -1313,6 +1319,8 @@
|
|||||||
|
|
||||||
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||||
|
|
||||||
|
"react-zoom-pan-pinch": ["react-zoom-pan-pinch@4.0.3", "", { "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-N2Hi6L78fFmhRra+ORpFSW7WST5x6kxpOPplIvtB0b7b+U2anpo1z1wLgaWRPS2kUSqcraRG+JgBCIlDJnqqAg=="],
|
||||||
|
|
||||||
"recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
|
"recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
|
||||||
|
|
||||||
"recharts": ["recharts@3.8.0", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ=="],
|
"recharts": ["recharts@3.8.0", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ=="],
|
||||||
|
|||||||
111
k-photos-frontend/components/add-to-album-dialog.tsx
Normal file
111
k-photos-frontend/components/add-to-album-dialog.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useAlbums } from "@/hooks/use-albums"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { PlusIcon } from "lucide-react"
|
||||||
|
|
||||||
|
interface AddToAlbumDialogProps {
|
||||||
|
assetIds: string[]
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddToAlbumDialog({
|
||||||
|
assetIds,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: AddToAlbumDialogProps) {
|
||||||
|
const { albums, isLoading, createAlbum, addEntry } = useAlbums()
|
||||||
|
const [newTitle, setNewTitle] = useState("")
|
||||||
|
const [adding, setAdding] = useState(false)
|
||||||
|
|
||||||
|
const handleAdd = async (albumId: string) => {
|
||||||
|
setAdding(true)
|
||||||
|
try {
|
||||||
|
for (const assetId of assetIds) {
|
||||||
|
await addEntry({ albumId, assetId }).catch(() => {})
|
||||||
|
}
|
||||||
|
toast.success(`Added ${assetIds.length} photo(s) to album`)
|
||||||
|
onOpenChange(false)
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to add to album")
|
||||||
|
} finally {
|
||||||
|
setAdding(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateAndAdd = async () => {
|
||||||
|
if (!newTitle.trim()) return
|
||||||
|
setAdding(true)
|
||||||
|
try {
|
||||||
|
const album = await createAlbum(newTitle.trim())
|
||||||
|
for (const assetId of assetIds) {
|
||||||
|
await addEntry({ albumId: album.id, assetId }).catch(() => {})
|
||||||
|
}
|
||||||
|
setNewTitle("")
|
||||||
|
toast.success(`Created album and added ${assetIds.length} photo(s)`)
|
||||||
|
onOpenChange(false)
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed")
|
||||||
|
} finally {
|
||||||
|
setAdding(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add to Album</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex min-h-0 flex-col gap-2 overflow-y-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
albums.map((album) => (
|
||||||
|
<Button
|
||||||
|
key={album.id}
|
||||||
|
variant="outline"
|
||||||
|
className="justify-start"
|
||||||
|
disabled={adding}
|
||||||
|
onClick={() => handleAdd(album.id)}
|
||||||
|
>
|
||||||
|
{album.title}
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
{album.asset_count}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 border-t pt-3">
|
||||||
|
<Input
|
||||||
|
placeholder="New album name"
|
||||||
|
value={newTitle}
|
||||||
|
onChange={(e) => setNewTitle(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleCreateAndAdd()}
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!newTitle.trim() || adding}
|
||||||
|
onClick={handleCreateAndAdd}
|
||||||
|
>
|
||||||
|
<PlusIcon className="mr-1 h-3 w-3" />
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
57
k-photos-frontend/components/admin-sidebar.tsx
Normal file
57
k-photos-frontend/components/admin-sidebar.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import { usePathname } from "next/navigation"
|
||||||
|
import { useAuth } from "@/hooks/use-auth"
|
||||||
|
import {
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarMenuButton,
|
||||||
|
} from "@/components/ui/sidebar"
|
||||||
|
import {
|
||||||
|
HardDriveIcon,
|
||||||
|
ListIcon,
|
||||||
|
PlugIcon,
|
||||||
|
WorkflowIcon,
|
||||||
|
FileTextIcon,
|
||||||
|
CopyIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
const ADMIN_LINKS = [
|
||||||
|
{ href: "/admin/storage", label: "Storage", icon: HardDriveIcon },
|
||||||
|
{ href: "/admin/jobs", label: "Jobs", icon: ListIcon },
|
||||||
|
{ href: "/admin/plugins", label: "Plugins", icon: PlugIcon },
|
||||||
|
{ href: "/admin/pipelines", label: "Pipelines", icon: WorkflowIcon },
|
||||||
|
{ href: "/admin/sidecars", label: "Sidecars", icon: FileTextIcon },
|
||||||
|
{ href: "/admin/duplicates", label: "Duplicates", icon: CopyIcon },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function AdminSidebar() {
|
||||||
|
const { isAdmin } = useAuth()
|
||||||
|
const pathname = usePathname()
|
||||||
|
|
||||||
|
if (!isAdmin) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel>Admin</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
{ADMIN_LINKS.map(({ href, label, icon: Icon }) => (
|
||||||
|
<SidebarMenuItem key={href}>
|
||||||
|
<SidebarMenuButton asChild isActive={pathname === href}>
|
||||||
|
<Link href={href}>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
<span>{label}</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
78
k-photos-frontend/components/album-sidebar.tsx
Normal file
78
k-photos-frontend/components/album-sidebar.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { usePathname } from "next/navigation"
|
||||||
|
import {
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarMenuButton,
|
||||||
|
} from "@/components/ui/sidebar"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { useAlbums } from "@/hooks/use-albums"
|
||||||
|
import { ImageIcon, PlusIcon } from "lucide-react"
|
||||||
|
|
||||||
|
export function AlbumSidebar() {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const { albums, createAlbum } = useAlbums()
|
||||||
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
|
const [newTitle, setNewTitle] = useState("")
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!newTitle.trim()) return
|
||||||
|
await createAlbum(newTitle.trim()).catch(() => {})
|
||||||
|
setNewTitle("")
|
||||||
|
setIsCreating(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel className="flex items-center justify-between">
|
||||||
|
Albums
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5"
|
||||||
|
onClick={() => setIsCreating(!isCreating)}
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
{isCreating && (
|
||||||
|
<div className="px-2 pb-2">
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
placeholder="Album title"
|
||||||
|
value={newTitle}
|
||||||
|
onChange={(e) => setNewTitle(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleCreate()
|
||||||
|
if (e.key === "Escape") setIsCreating(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<SidebarMenu>
|
||||||
|
{albums.map((album) => (
|
||||||
|
<SidebarMenuItem key={album.id}>
|
||||||
|
<SidebarMenuButton
|
||||||
|
asChild
|
||||||
|
isActive={pathname === `/albums/${album.id}`}
|
||||||
|
>
|
||||||
|
<Link href={`/albums/${album.id}`}>
|
||||||
|
<ImageIcon className="h-4 w-4" />
|
||||||
|
<span>{album.title}</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
103
k-photos-frontend/components/asset-picker-dialog.tsx
Normal file
103
k-photos-frontend/components/asset-picker-dialog.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useCallback } from "react"
|
||||||
|
import { useTimeline } from "@/hooks/use-timeline"
|
||||||
|
import { PhotoCard } from "./photo-card"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
|
|
||||||
|
interface AssetPickerDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
excludeIds?: Set<string>
|
||||||
|
onConfirm: (assetIds: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssetPickerDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
excludeIds,
|
||||||
|
onConfirm,
|
||||||
|
}: AssetPickerDialogProps) {
|
||||||
|
const { assets, isLoading, hasMore, loadMore } = useTimeline()
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const toggle = useCallback((id: string, sel: boolean) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (sel) next.add(id)
|
||||||
|
else next.delete(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const filtered = excludeIds
|
||||||
|
? assets.filter((a) => !excludeIds.has(a.id))
|
||||||
|
: assets
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
onConfirm(Array.from(selected))
|
||||||
|
setSelected(new Set())
|
||||||
|
onOpenChange(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) setSelected(new Set())
|
||||||
|
onOpenChange(o)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="flex flex-col sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Select Photos</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
|
<div className="grid grid-cols-3 gap-1 sm:grid-cols-4 md:grid-cols-5">
|
||||||
|
{filtered.map((asset) => (
|
||||||
|
<PhotoCard
|
||||||
|
key={asset.id}
|
||||||
|
asset={asset}
|
||||||
|
selectable
|
||||||
|
selected={selected.has(asset.id)}
|
||||||
|
onSelect={(sel) => toggle(asset.id, sel)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{hasMore && (
|
||||||
|
<div className="flex justify-center py-3">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => loadMore()}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? <Spinner /> : "Load more"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
<div className="flex items-center justify-between border-t pt-3">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{selected.size} selected
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={selected.size === 0}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
>
|
||||||
|
Add to Album
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
81
k-photos-frontend/components/auth-provider.tsx
Normal file
81
k-photos-frontend/components/auth-provider.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { createContext, useState, useEffect, useCallback, type ReactNode } from "react"
|
||||||
|
import api from "@/lib/api"
|
||||||
|
import { getTokens, setTokens, clearTokens } from "@/lib/auth"
|
||||||
|
import type { AuthResponse, UserResponse } from "@/lib/types"
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
user: UserResponse | null
|
||||||
|
isAuthenticated: boolean
|
||||||
|
isAdmin: boolean
|
||||||
|
isLoading: boolean
|
||||||
|
login: (email: string, password: string) => Promise<void>
|
||||||
|
register: (username: string, email: string, password: string) => Promise<void>
|
||||||
|
logout: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthContext = createContext<AuthContextValue | null>(null)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<UserResponse | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const { access } = getTokens()
|
||||||
|
if (!access) {
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api
|
||||||
|
.get<UserResponse>("/auth/me")
|
||||||
|
.then((res) => setUser(res.data))
|
||||||
|
.catch(() => clearTokens())
|
||||||
|
.finally(() => setIsLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const login = useCallback(async (email: string, password: string) => {
|
||||||
|
const { data } = await api.post<AuthResponse>("/auth/login", {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
setTokens(data.token, data.refresh_token)
|
||||||
|
setUser(data.user)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const register = useCallback(
|
||||||
|
async (username: string, email: string, password: string) => {
|
||||||
|
const { data } = await api.post<AuthResponse>("/auth/register", {
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
setTokens(data.token, data.refresh_token)
|
||||||
|
setUser(data.user)
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
api.post("/auth/logout").catch(() => {})
|
||||||
|
clearTokens()
|
||||||
|
setUser(null)
|
||||||
|
window.location.href = "/login"
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
user,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
isAdmin: user?.role === "admin",
|
||||||
|
isLoading,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
145
k-photos-frontend/components/date-scrubber.tsx
Normal file
145
k-photos-frontend/components/date-scrubber.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState, useMemo, useCallback, useRef } from "react"
|
||||||
|
import { format, parseISO } from "date-fns"
|
||||||
|
import type { DateCountEntry } from "@/lib/types"
|
||||||
|
|
||||||
|
interface DateScrubberProps {
|
||||||
|
dates: DateCountEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScrubberEntry {
|
||||||
|
label: string
|
||||||
|
date: string
|
||||||
|
dateId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function findVisibleDateId(): string | null {
|
||||||
|
const headers = document.querySelectorAll<HTMLElement>("[data-date]")
|
||||||
|
const viewportTop = window.scrollY + window.innerHeight * 0.15
|
||||||
|
let best: HTMLElement | null = null
|
||||||
|
for (const h of headers) {
|
||||||
|
if (h.offsetTop <= viewportTop) best = h
|
||||||
|
else break
|
||||||
|
}
|
||||||
|
return best?.id ?? headers[0]?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DateScrubber({ dates }: DateScrubberProps) {
|
||||||
|
const [activeDate, setActiveDate] = useState<string | null>(null)
|
||||||
|
const scrollingRef = useRef(false)
|
||||||
|
|
||||||
|
const entries = useMemo<ScrubberEntry[]>(() => {
|
||||||
|
const compact = dates.length > 30
|
||||||
|
|
||||||
|
let lastYear = ""
|
||||||
|
let lastMonth = ""
|
||||||
|
const result: ScrubberEntry[] = []
|
||||||
|
|
||||||
|
for (const { date } of dates) {
|
||||||
|
const d = parseISO(date)
|
||||||
|
const monthKey = format(d, "yyyy-MM")
|
||||||
|
|
||||||
|
if (compact && monthKey === lastMonth) continue
|
||||||
|
lastMonth = monthKey
|
||||||
|
|
||||||
|
const year = format(d, "yyyy")
|
||||||
|
const showYear = year !== lastYear
|
||||||
|
lastYear = year
|
||||||
|
|
||||||
|
const label = compact
|
||||||
|
? showYear
|
||||||
|
? format(d, "MMM yyyy")
|
||||||
|
: format(d, "MMM")
|
||||||
|
: showYear
|
||||||
|
? format(d, "MMM d, yyyy")
|
||||||
|
: format(d, "MMM d")
|
||||||
|
|
||||||
|
result.push({ label, date, dateId: `date-${date}` })
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}, [dates])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let raf = 0
|
||||||
|
const onScroll = () => {
|
||||||
|
cancelAnimationFrame(raf)
|
||||||
|
raf = requestAnimationFrame(() => {
|
||||||
|
if (!scrollingRef.current) {
|
||||||
|
setActiveDate(findVisibleDateId())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
window.addEventListener("scroll", onScroll, { passive: true })
|
||||||
|
onScroll()
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", onScroll)
|
||||||
|
cancelAnimationFrame(raf)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const scrollToDate = useCallback((dateId: string) => {
|
||||||
|
const el = document.getElementById(dateId)
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
scrollingRef.current = true
|
||||||
|
setActiveDate(dateId)
|
||||||
|
el.scrollIntoView({ behavior: "smooth", block: "start" })
|
||||||
|
setTimeout(() => {
|
||||||
|
scrollingRef.current = false
|
||||||
|
}, 800)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(entry: ScrubberEntry) => {
|
||||||
|
const el = document.getElementById(entry.dateId)
|
||||||
|
if (el) {
|
||||||
|
scrollToDate(entry.dateId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = Array.from(
|
||||||
|
document.querySelectorAll<HTMLElement>("[data-date]"),
|
||||||
|
)
|
||||||
|
let closest: HTMLElement | null = null
|
||||||
|
for (const h of headers) {
|
||||||
|
const d = h.dataset.date ?? ""
|
||||||
|
if (d >= entry.date) {
|
||||||
|
closest = h
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!closest) closest = headers[headers.length - 1] ?? null
|
||||||
|
|
||||||
|
if (closest) {
|
||||||
|
scrollingRef.current = true
|
||||||
|
setActiveDate(entry.dateId)
|
||||||
|
closest.scrollIntoView({ behavior: "smooth", block: "start" })
|
||||||
|
setTimeout(() => {
|
||||||
|
scrollingRef.current = false
|
||||||
|
}, 800)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[scrollToDate],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (entries.length < 1) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sticky top-0 flex h-[calc(100svh-7rem)] w-8 shrink-0 flex-col items-center justify-start gap-0.5 overflow-y-auto py-2">
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<button
|
||||||
|
key={entry.dateId}
|
||||||
|
onClick={() => handleClick(entry)}
|
||||||
|
className={`w-full rounded px-0.5 py-0.5 text-center text-[9px] leading-tight transition-colors ${
|
||||||
|
activeDate === entry.dateId
|
||||||
|
? "font-semibold text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{entry.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
183
k-photos-frontend/components/image-viewer.tsx
Normal file
183
k-photos-frontend/components/image-viewer.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback, useRef } from "react"
|
||||||
|
import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch"
|
||||||
|
import type { AssetResponse } from "@/lib/types"
|
||||||
|
import { getTokens } from "@/lib/auth"
|
||||||
|
import { MetadataSidebar } from "./metadata-sidebar"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import {
|
||||||
|
XIcon,
|
||||||
|
ZoomInIcon,
|
||||||
|
ZoomOutIcon,
|
||||||
|
Maximize2Icon,
|
||||||
|
InfoIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
interface ImageViewerProps {
|
||||||
|
assets: AssetResponse[]
|
||||||
|
initialIndex: number
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageViewer({ assets, initialIndex, onClose }: ImageViewerProps) {
|
||||||
|
const [index, setIndex] = useState(initialIndex)
|
||||||
|
const [src, setSrc] = useState<string | null>(null)
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||||
|
const prevBlobRef = useRef<string | null>(null)
|
||||||
|
|
||||||
|
const asset = assets[index]
|
||||||
|
const hasPrev = index > 0
|
||||||
|
const hasNext = index < assets.length - 1
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevBlobRef.current) URL.revokeObjectURL(prevBlobRef.current)
|
||||||
|
setSrc(null)
|
||||||
|
|
||||||
|
const { access } = getTokens()
|
||||||
|
fetch(`/api/v1/assets/${asset.id}/file`, {
|
||||||
|
headers: access ? { Authorization: `Bearer ${access}` } : {},
|
||||||
|
})
|
||||||
|
.then((r) => (r.ok ? r.blob() : Promise.reject()))
|
||||||
|
.then((blob) => {
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
prevBlobRef.current = url
|
||||||
|
setSrc(url)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (prevBlobRef.current) {
|
||||||
|
URL.revokeObjectURL(prevBlobRef.current)
|
||||||
|
prevBlobRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [asset.id])
|
||||||
|
|
||||||
|
const goPrev = useCallback(() => {
|
||||||
|
if (hasPrev) setIndex((i) => i - 1)
|
||||||
|
}, [hasPrev])
|
||||||
|
|
||||||
|
const goNext = useCallback(() => {
|
||||||
|
if (hasNext) setIndex((i) => i + 1)
|
||||||
|
}, [hasNext])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onClose()
|
||||||
|
if (e.key === "ArrowLeft") goPrev()
|
||||||
|
if (e.key === "ArrowRight") goNext()
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKey)
|
||||||
|
return () => window.removeEventListener("keydown", onKey)
|
||||||
|
}, [onClose, goPrev, goNext])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex bg-black">
|
||||||
|
{/* Main image area */}
|
||||||
|
<div className="relative flex flex-1 items-center justify-center">
|
||||||
|
<TransformWrapper
|
||||||
|
key={asset.id}
|
||||||
|
initialScale={1}
|
||||||
|
minScale={0.5}
|
||||||
|
maxScale={10}
|
||||||
|
centerOnInit
|
||||||
|
wheel={{ step: 0.1 }}
|
||||||
|
>
|
||||||
|
{({ zoomIn, zoomOut, resetTransform }) => (
|
||||||
|
<>
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="absolute top-0 right-0 left-0 z-10 flex items-center justify-between p-3">
|
||||||
|
<div />
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<ToolbarButton onClick={() => zoomIn()} icon={<ZoomInIcon />} />
|
||||||
|
<ToolbarButton onClick={() => zoomOut()} icon={<ZoomOutIcon />} />
|
||||||
|
<ToolbarButton onClick={() => resetTransform()} icon={<Maximize2Icon />} />
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||||
|
icon={<InfoIcon />}
|
||||||
|
active={sidebarOpen}
|
||||||
|
/>
|
||||||
|
<ToolbarButton onClick={onClose} icon={<XIcon />} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image */}
|
||||||
|
<TransformComponent
|
||||||
|
wrapperStyle={{ width: "100%", height: "100%" }}
|
||||||
|
contentStyle={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{src ? (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt=""
|
||||||
|
className="max-h-full max-w-full object-contain"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Skeleton className="h-96 w-96 rounded-lg" />
|
||||||
|
)}
|
||||||
|
</TransformComponent>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TransformWrapper>
|
||||||
|
|
||||||
|
{/* Navigation arrows */}
|
||||||
|
{hasPrev && (
|
||||||
|
<button
|
||||||
|
onClick={goPrev}
|
||||||
|
className="absolute left-3 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{hasNext && (
|
||||||
|
<button
|
||||||
|
onClick={goNext}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
|
||||||
|
>
|
||||||
|
<ChevronRightIcon className="h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata sidebar */}
|
||||||
|
{sidebarOpen && (
|
||||||
|
<MetadataSidebar
|
||||||
|
asset={asset}
|
||||||
|
onClose={() => setSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolbarButton({
|
||||||
|
onClick,
|
||||||
|
icon,
|
||||||
|
active,
|
||||||
|
}: {
|
||||||
|
onClick: () => void
|
||||||
|
icon: React.ReactNode
|
||||||
|
active?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onClick}
|
||||||
|
className={`h-8 w-8 text-white hover:bg-white/20 ${active ? "bg-white/20" : ""}`}
|
||||||
|
>
|
||||||
|
<span className="h-4 w-4">{icon}</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
204
k-photos-frontend/components/metadata-sidebar.tsx
Normal file
204
k-photos-frontend/components/metadata-sidebar.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { AssetResponse } from "@/lib/types"
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { ChevronDownIcon, XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
interface MetadataSidebarProps {
|
||||||
|
asset: AssetResponse
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CAMERA_KEYS = [
|
||||||
|
"Make",
|
||||||
|
"Model",
|
||||||
|
"FocalLength",
|
||||||
|
"FocalLengthIn35mmFilm",
|
||||||
|
"FNumber",
|
||||||
|
"ExposureTime",
|
||||||
|
"ISOSpeedRatings",
|
||||||
|
"ExposureMode",
|
||||||
|
"ExposureProgram",
|
||||||
|
"MeteringMode",
|
||||||
|
"Flash",
|
||||||
|
"WhiteBalanceMode",
|
||||||
|
"LightSource",
|
||||||
|
]
|
||||||
|
|
||||||
|
const GPS_KEYS = ["GPSInfo", "GPSLatitude", "GPSLongitude", "GPSAltitude"]
|
||||||
|
|
||||||
|
const HIDDEN_KEYS = [
|
||||||
|
"file_size_bytes",
|
||||||
|
"mime_type",
|
||||||
|
"ExifImageWidth",
|
||||||
|
"ExifImageHeight",
|
||||||
|
...CAMERA_KEYS,
|
||||||
|
...GPS_KEYS,
|
||||||
|
]
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatExposure(val: string): string {
|
||||||
|
const match = val.match(/^(\d+)\/(\d+)/)
|
||||||
|
if (!match) return val
|
||||||
|
const num = parseInt(match[1])
|
||||||
|
const den = parseInt(match[2])
|
||||||
|
if (den === 0) return val
|
||||||
|
const result = num / den
|
||||||
|
return result < 1 ? `1/${Math.round(1 / result)}s` : `${result}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFocalLength(val: string): string {
|
||||||
|
const match = val.match(/^(\d+)\/(\d+)/)
|
||||||
|
if (!match) return val
|
||||||
|
return `${Math.round(parseInt(match[1]) / parseInt(match[2]))}mm`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFNumber(val: string): string {
|
||||||
|
const match = val.match(/^(\d+)\/(\d+)/)
|
||||||
|
if (!match) return val
|
||||||
|
return `f/${(parseInt(match[1]) / parseInt(match[2])).toFixed(1)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetaRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between gap-4 py-0.5 text-xs">
|
||||||
|
<span className="shrink-0 text-zinc-400">{label}</span>
|
||||||
|
<span className="truncate text-right text-zinc-200">{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
title,
|
||||||
|
defaultOpen = true,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
defaultOpen?: boolean
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Collapsible defaultOpen={defaultOpen}>
|
||||||
|
<CollapsibleTrigger className="flex w-full items-center justify-between py-2 text-xs font-medium text-zinc-300 hover:text-white">
|
||||||
|
{title}
|
||||||
|
<ChevronDownIcon className="h-3.5 w-3.5 transition-transform [[data-state=closed]>&]:rotate-(-90)" />
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="pb-3">{children}</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetadataSidebar({ asset, onClose }: MetadataSidebarProps) {
|
||||||
|
const meta = asset.metadata
|
||||||
|
const width = meta.ExifImageWidth as string | undefined
|
||||||
|
const height = meta.ExifImageHeight as string | undefined
|
||||||
|
|
||||||
|
const cameraEntries = CAMERA_KEYS.filter((k) => meta[k] != null).map(
|
||||||
|
(k) => {
|
||||||
|
let val = String(meta[k])
|
||||||
|
if (k === "ExposureTime") val = formatExposure(val)
|
||||||
|
if (k === "FocalLength") val = formatFocalLength(val)
|
||||||
|
if (k === "FNumber") val = formatFNumber(val)
|
||||||
|
return [k, val] as const
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const gpsEntries = GPS_KEYS.filter((k) => meta[k] != null)
|
||||||
|
|
||||||
|
const remainingEntries = Object.entries(meta).filter(
|
||||||
|
([k]) => !HIDDEN_KEYS.includes(k),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-80 shrink-0 flex-col border-l border-zinc-800 bg-zinc-950/90 backdrop-blur">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3">
|
||||||
|
<span className="text-sm font-medium text-zinc-200">Details</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-zinc-400 hover:text-white hover:bg-white/10"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="bg-zinc-800" />
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1 px-4">
|
||||||
|
{/* File Info */}
|
||||||
|
<Section title="File Info">
|
||||||
|
<div className="flex items-center gap-2 pb-1">
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{asset.asset_type}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-zinc-400">{asset.mime_type}</span>
|
||||||
|
</div>
|
||||||
|
<MetaRow label="Size" value={formatBytes(asset.file_size)} />
|
||||||
|
{width && height && (
|
||||||
|
<MetaRow label="Dimensions" value={`${width} × ${height}`} />
|
||||||
|
)}
|
||||||
|
<MetaRow
|
||||||
|
label="Created"
|
||||||
|
value={new Date(asset.created_at).toLocaleString()}
|
||||||
|
/>
|
||||||
|
<MetaRow
|
||||||
|
label="Processed"
|
||||||
|
value={asset.is_processed ? "Yes" : "No"}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Camera */}
|
||||||
|
{cameraEntries.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator className="bg-zinc-800" />
|
||||||
|
<Section title="Camera">
|
||||||
|
{cameraEntries.map(([k, v]) => (
|
||||||
|
<MetaRow key={k} label={k} value={v} />
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Location */}
|
||||||
|
{gpsEntries.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator className="bg-zinc-800" />
|
||||||
|
<Section title="Location">
|
||||||
|
{gpsEntries.map((k) => (
|
||||||
|
<MetaRow key={k} label={k} value={String(meta[k])} />
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* All Metadata */}
|
||||||
|
{remainingEntries.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator className="bg-zinc-800" />
|
||||||
|
<Section title="All Metadata" defaultOpen={false}>
|
||||||
|
{remainingEntries.map(([k, v]) => (
|
||||||
|
<MetaRow key={k} label={k} value={String(v)} />
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
86
k-photos-frontend/components/photo-card.tsx
Normal file
86
k-photos-frontend/components/photo-card.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import type { AssetResponse } from "@/lib/types"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import { getTokens } from "@/lib/auth"
|
||||||
|
import { ImageIcon } from "lucide-react"
|
||||||
|
|
||||||
|
interface PhotoCardProps {
|
||||||
|
asset: AssetResponse
|
||||||
|
selected?: boolean
|
||||||
|
selectable?: boolean
|
||||||
|
onClick?: () => void
|
||||||
|
onSelect?: (selected: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PhotoCard({
|
||||||
|
asset,
|
||||||
|
selected,
|
||||||
|
selectable,
|
||||||
|
onClick,
|
||||||
|
onSelect,
|
||||||
|
}: PhotoCardProps) {
|
||||||
|
const [src, setSrc] = useState<string | null>(null)
|
||||||
|
const [failed, setFailed] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let revoke: string | null = null
|
||||||
|
setFailed(false)
|
||||||
|
const { access } = getTokens()
|
||||||
|
const headers: HeadersInit = access
|
||||||
|
? { Authorization: `Bearer ${access}` }
|
||||||
|
: {}
|
||||||
|
fetch(`/api/v1/assets/${asset.id}/derivatives/thumbnail_square`, {
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
.then((r) => (r.ok ? r.blob() : Promise.reject()))
|
||||||
|
.then((blob) => {
|
||||||
|
revoke = URL.createObjectURL(blob)
|
||||||
|
setSrc(revoke)
|
||||||
|
})
|
||||||
|
.catch(() => setFailed(true))
|
||||||
|
return () => {
|
||||||
|
if (revoke) URL.revokeObjectURL(revoke)
|
||||||
|
}
|
||||||
|
}, [asset.id])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`group relative aspect-square cursor-pointer overflow-hidden rounded-md bg-muted ${selected ? "ring-2 ring-primary ring-offset-2" : ""}`}
|
||||||
|
onClick={selectable ? () => onSelect?.(!selected) : onClick}
|
||||||
|
>
|
||||||
|
{src ? (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
) : failed ? (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<ImageIcon className="h-8 w-8 text-muted-foreground/40" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Skeleton className="h-full w-full" />
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-black/0 transition-colors group-hover:bg-black/30" />
|
||||||
|
{selectable && (
|
||||||
|
<div className="absolute top-1.5 left-1.5">
|
||||||
|
<Checkbox
|
||||||
|
checked={selected}
|
||||||
|
onCheckedChange={(c) => onSelect?.(c === true)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="border-white bg-black/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 translate-y-full p-2 transition-transform group-hover:translate-y-0">
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{asset.asset_type}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
180
k-photos-frontend/components/photo-grid.tsx
Normal file
180
k-photos-frontend/components/photo-grid.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, useMemo, useCallback } from "react"
|
||||||
|
import type { AssetResponse } from "@/lib/types"
|
||||||
|
import type { DateGroup } from "@/lib/timeline"
|
||||||
|
import { PhotoCard } from "./photo-card"
|
||||||
|
import { ImageViewer } from "./image-viewer"
|
||||||
|
import { AddToAlbumDialog } from "./add-to-album-dialog"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
|
import { ImagePlusIcon, XIcon, CheckSquareIcon } from "lucide-react"
|
||||||
|
|
||||||
|
interface PhotoGridProps {
|
||||||
|
groups: DateGroup[]
|
||||||
|
isLoading: boolean
|
||||||
|
hasMore: boolean
|
||||||
|
onLoadMore: () => void
|
||||||
|
onRemoveAsset?: (assetId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PhotoGrid({
|
||||||
|
groups,
|
||||||
|
isLoading,
|
||||||
|
hasMore,
|
||||||
|
onLoadMore,
|
||||||
|
onRemoveAsset,
|
||||||
|
}: PhotoGridProps) {
|
||||||
|
const sentinelRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
|
||||||
|
const [selecting, setSelecting] = useState(false)
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
|
const [albumDialogOpen, setAlbumDialogOpen] = useState(false)
|
||||||
|
|
||||||
|
const allAssets = useMemo(
|
||||||
|
() => groups.flatMap((g) => g.assets),
|
||||||
|
[groups],
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggleSelect = useCallback((id: string, selected: boolean) => {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (selected) next.add(id)
|
||||||
|
else next.delete(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const exitSelection = useCallback(() => {
|
||||||
|
setSelecting(false)
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = sentinelRef.current
|
||||||
|
if (!el) return
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) onLoadMore()
|
||||||
|
},
|
||||||
|
{ rootMargin: "200px" },
|
||||||
|
)
|
||||||
|
observer.observe(el)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [onLoadMore])
|
||||||
|
|
||||||
|
if (allAssets.length === 0 && !isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 items-center justify-center text-muted-foreground">
|
||||||
|
No photos yet. Upload some to get started.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let flatIndex = 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col">
|
||||||
|
{/* Selection toolbar */}
|
||||||
|
{selecting && (
|
||||||
|
<div className="sticky top-0 z-20 flex items-center gap-2 rounded-md border bg-background px-3 py-2 shadow-sm">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{selectedIds.size} selected
|
||||||
|
</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={selectedIds.size === 0}
|
||||||
|
onClick={() => setAlbumDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<ImagePlusIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Add to Album
|
||||||
|
</Button>
|
||||||
|
{onRemoveAsset && selectedIds.size > 0 && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
selectedIds.forEach((id) => onRemoveAsset(id))
|
||||||
|
exitSelection()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="ghost" onClick={exitSelection}>
|
||||||
|
<XIcon className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!selecting && allAssets.length > 0 && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
onClick={() => setSelecting(true)}
|
||||||
|
>
|
||||||
|
<CheckSquareIcon className="mr-1 h-3.5 w-3.5" />
|
||||||
|
Select
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{groups.map((group) => {
|
||||||
|
const startIndex = flatIndex
|
||||||
|
flatIndex += group.assets.length
|
||||||
|
return (
|
||||||
|
<div key={group.date}>
|
||||||
|
<h2
|
||||||
|
id={`date-${group.date}`}
|
||||||
|
data-date={group.date}
|
||||||
|
className="sticky top-0 z-10 bg-background/80 py-1.5 text-sm font-medium backdrop-blur"
|
||||||
|
>
|
||||||
|
{group.label}
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-1 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||||
|
{group.assets.map((asset, j) => (
|
||||||
|
<PhotoCard
|
||||||
|
key={asset.id}
|
||||||
|
asset={asset}
|
||||||
|
selectable={selecting}
|
||||||
|
selected={selectedIds.has(asset.id)}
|
||||||
|
onSelect={(sel) => toggleSelect(asset.id, sel)}
|
||||||
|
onClick={() => setSelectedIndex(startIndex + j)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{hasMore && <div ref={sentinelRef} className="h-1" />}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex justify-center py-4">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedIndex !== null && (
|
||||||
|
<ImageViewer
|
||||||
|
assets={allAssets}
|
||||||
|
initialIndex={selectedIndex}
|
||||||
|
onClose={() => setSelectedIndex(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AddToAlbumDialog
|
||||||
|
assetIds={Array.from(selectedIds)}
|
||||||
|
open={albumDialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setAlbumDialogOpen(open)
|
||||||
|
if (!open) exitSelection()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
k-photos-frontend/components/query-provider.tsx
Normal file
9
k-photos-frontend/components/query-provider.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||||
|
import { useState, type ReactNode } from "react"
|
||||||
|
|
||||||
|
export function QueryProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [client] = useState(() => new QueryClient())
|
||||||
|
return <QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||||
|
}
|
||||||
@@ -87,6 +87,7 @@ function Calendar({
|
|||||||
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
|
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
|
||||||
defaultClassNames.caption_label
|
defaultClassNames.caption_label
|
||||||
),
|
),
|
||||||
|
// @ts-expect-error react-day-picker v10 type mismatch
|
||||||
table: "w-full border-collapse",
|
table: "w-full border-collapse",
|
||||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||||
weekday: cn(
|
weekday: cn(
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ function DialogContent({
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-6 rounded-xl bg-popover p-6 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-md data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] max-h-[calc(100vh-2rem)] -translate-x-1/2 -translate-y-1/2 gap-6 overflow-y-auto rounded-xl bg-popover p-6 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-md data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
107
k-photos-frontend/components/upload-dialog.tsx
Normal file
107
k-photos-frontend/components/upload-dialog.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef, type DragEvent } from "react"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Progress } from "@/components/ui/progress"
|
||||||
|
import { useUpload } from "@/hooks/use-upload"
|
||||||
|
import { UploadIcon } from "lucide-react"
|
||||||
|
|
||||||
|
interface UploadDialogProps {
|
||||||
|
onComplete?: () => void
|
||||||
|
children?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UploadDialog({ onComplete, children }: UploadDialogProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const { uploads, isUploading, upload } = useUpload(() => {
|
||||||
|
onComplete?.()
|
||||||
|
setOpen(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleFiles = useCallback(
|
||||||
|
(files: FileList | null) => {
|
||||||
|
if (!files || files.length === 0) return
|
||||||
|
upload(Array.from(files))
|
||||||
|
},
|
||||||
|
[upload],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onDrop = useCallback(
|
||||||
|
(e: DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsDragging(false)
|
||||||
|
handleFiles(e.dataTransfer.files)
|
||||||
|
},
|
||||||
|
[handleFiles],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onDragOver = useCallback((e: DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsDragging(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onDragLeave = useCallback(() => setIsDragging(false), [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{children ?? (
|
||||||
|
<Button size="sm">
|
||||||
|
<UploadIcon className="mr-2 h-4 w-4" />
|
||||||
|
Upload
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Upload Photos</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div
|
||||||
|
onDrop={onDrop}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
className={`flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-8 transition-colors ${isDragging ? "border-primary bg-primary/5" : "border-muted-foreground/25 hover:border-muted-foreground/50"}`}
|
||||||
|
>
|
||||||
|
<UploadIcon className="h-8 w-8 text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Drag & drop files here, or click to browse
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept="image/*,video/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => handleFiles(e.target.files)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{uploads.length > 0 && (
|
||||||
|
<div className="flex min-h-0 flex-col gap-2 overflow-y-auto">
|
||||||
|
{uploads.map((u) => (
|
||||||
|
<div key={u.file} className="flex flex-col gap-1">
|
||||||
|
<span className="truncate text-xs">{u.file}</span>
|
||||||
|
<Progress value={u.progress} className="h-1.5" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isUploading && (
|
||||||
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
Uploading...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
67
k-photos-frontend/hooks/use-albums.ts
Normal file
67
k-photos-frontend/hooks/use-albums.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import api from "@/lib/api"
|
||||||
|
import type {
|
||||||
|
AlbumResponse,
|
||||||
|
CreateAlbumRequest,
|
||||||
|
UpdateAlbumRequest,
|
||||||
|
} from "@/lib/types"
|
||||||
|
|
||||||
|
export function useAlbums() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ["albums"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get<AlbumResponse[]>("/albums")
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: async (title: string) => {
|
||||||
|
const body: CreateAlbumRequest = { title }
|
||||||
|
const { data } = await api.post<AlbumResponse>("/albums", body)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["albums"] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const update = useMutation({
|
||||||
|
mutationFn: async ({ id, ...updates }: UpdateAlbumRequest & { id: string }) => {
|
||||||
|
const { data } = await api.put<AlbumResponse>(`/albums/${id}`, updates)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["albums"] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const addEntry = useMutation({
|
||||||
|
mutationFn: async ({ albumId, assetId }: { albumId: string; assetId: string }) => {
|
||||||
|
await api.post(`/albums/${albumId}/entries`, { asset_id: assetId })
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["albums"] })
|
||||||
|
qc.invalidateQueries({ queryKey: ["album"] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const removeEntry = useMutation({
|
||||||
|
mutationFn: async ({ albumId, assetId }: { albumId: string; assetId: string }) => {
|
||||||
|
await api.delete(`/albums/${albumId}/entries/${assetId}`)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["albums"] })
|
||||||
|
qc.invalidateQueries({ queryKey: ["album"] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
albums: query.data ?? [],
|
||||||
|
isLoading: query.isLoading,
|
||||||
|
createAlbum: create.mutateAsync,
|
||||||
|
updateAlbum: update.mutateAsync,
|
||||||
|
addEntry: addEntry.mutateAsync,
|
||||||
|
removeEntry: removeEntry.mutateAsync,
|
||||||
|
}
|
||||||
|
}
|
||||||
10
k-photos-frontend/hooks/use-auth.ts
Normal file
10
k-photos-frontend/hooks/use-auth.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useContext } from "react"
|
||||||
|
import { AuthContext } from "@/components/auth-provider"
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext)
|
||||||
|
if (!ctx) throw new Error("useAuth must be used within AuthProvider")
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
35
k-photos-frontend/hooks/use-duplicates.ts
Normal file
35
k-photos-frontend/hooks/use-duplicates.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import api from "@/lib/api"
|
||||||
|
import type { DuplicateGroupResponse } from "@/lib/types"
|
||||||
|
|
||||||
|
export function useDuplicates() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["admin", "duplicates"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } =
|
||||||
|
await api.get<DuplicateGroupResponse[]>("/duplicates")
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResolveDuplicate() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
groupId,
|
||||||
|
keepAssetId,
|
||||||
|
}: {
|
||||||
|
groupId: string
|
||||||
|
keepAssetId: string
|
||||||
|
}) => {
|
||||||
|
await api.post(`/duplicates/${groupId}/resolve`, {
|
||||||
|
keep_asset_id: keepAssetId,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onSuccess: () =>
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin", "duplicates"] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
89
k-photos-frontend/hooks/use-jobs.ts
Normal file
89
k-photos-frontend/hooks/use-jobs.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import api from "@/lib/api"
|
||||||
|
import type { JobListResponse, BatchProgressResponse } from "@/lib/types"
|
||||||
|
|
||||||
|
const PAGE_SIZE = 25
|
||||||
|
|
||||||
|
export function useJobs(status?: string, offset = 0) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["admin", "jobs", status, offset],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get<JobListResponse>("/jobs", {
|
||||||
|
params: { status, limit: PAGE_SIZE, offset },
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
refetchInterval: 5000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { PAGE_SIZE as JOBS_PAGE_SIZE }
|
||||||
|
|
||||||
|
export function useEnqueueJob() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: {
|
||||||
|
job_type: string
|
||||||
|
priority?: number
|
||||||
|
payload?: Record<string, unknown>
|
||||||
|
target_asset_id?: string
|
||||||
|
batch_id?: string
|
||||||
|
}) => {
|
||||||
|
const { data } = await api.post("/jobs", body)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "jobs"] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStartJob() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (jobId: string) => {
|
||||||
|
await api.post(`/jobs/${jobId}/start`)
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "jobs"] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCompleteJob() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
jobId,
|
||||||
|
result,
|
||||||
|
}: {
|
||||||
|
jobId: string
|
||||||
|
result: Record<string, unknown>
|
||||||
|
}) => {
|
||||||
|
await api.post(`/jobs/${jobId}/complete`, { result })
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "jobs"] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFailJob() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ jobId, error }: { jobId: string; error: string }) => {
|
||||||
|
await api.post(`/jobs/${jobId}/fail`, { error })
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "jobs"] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBatchProgress(batchId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["admin", "batch", batchId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get<BatchProgressResponse>(
|
||||||
|
`/jobs/batches/${batchId}`,
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
enabled: !!batchId,
|
||||||
|
refetchInterval: 3000,
|
||||||
|
})
|
||||||
|
}
|
||||||
30
k-photos-frontend/hooks/use-pipelines.ts
Normal file
30
k-photos-frontend/hooks/use-pipelines.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import api from "@/lib/api"
|
||||||
|
import type { PipelineResponse } from "@/lib/types"
|
||||||
|
|
||||||
|
export function usePipelines() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["admin", "pipelines"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get<PipelineResponse[]>("/pipelines")
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConfigurePipeline() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: {
|
||||||
|
trigger_event: string
|
||||||
|
steps: { plugin_id: string; config: Record<string, unknown> }[]
|
||||||
|
}) => {
|
||||||
|
const { data } = await api.post<PipelineResponse>("/pipelines", body)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
onSuccess: () =>
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin", "pipelines"] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
32
k-photos-frontend/hooks/use-plugins.ts
Normal file
32
k-photos-frontend/hooks/use-plugins.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import api from "@/lib/api"
|
||||||
|
import type { PluginResponse } from "@/lib/types"
|
||||||
|
|
||||||
|
export function usePlugins() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["admin", "plugins"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get<PluginResponse[]>("/plugins")
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useManagePlugin() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: {
|
||||||
|
action: string
|
||||||
|
plugin_id?: string
|
||||||
|
name?: string
|
||||||
|
plugin_type?: string
|
||||||
|
config?: Record<string, unknown>
|
||||||
|
}) => {
|
||||||
|
const { data } = await api.post<PluginResponse>("/plugins", body)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "plugins"] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
71
k-photos-frontend/hooks/use-sidecars.ts
Normal file
71
k-photos-frontend/hooks/use-sidecars.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMutation } from "@tanstack/react-query"
|
||||||
|
import api from "@/lib/api"
|
||||||
|
import type {
|
||||||
|
SidecarExportResponse,
|
||||||
|
SidecarImportResponse,
|
||||||
|
DetectChangesResponse,
|
||||||
|
} from "@/lib/types"
|
||||||
|
|
||||||
|
export function useExportSidecar() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (assetId: string) => {
|
||||||
|
const { data } = await api.post<SidecarExportResponse>(
|
||||||
|
`/sidecar/export/${assetId}`,
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useImportSidecar() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (assetId: string) => {
|
||||||
|
const { data } = await api.post<SidecarImportResponse>(
|
||||||
|
`/sidecar/import/${assetId}`,
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDetectChanges() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { data } =
|
||||||
|
await api.post<DetectChangesResponse>("/sidecar/detect-changes")
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResolveSidecarConflict() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
assetId,
|
||||||
|
policy,
|
||||||
|
}: {
|
||||||
|
assetId: string
|
||||||
|
policy: string
|
||||||
|
}) => {
|
||||||
|
await api.post(`/sidecar/resolve/${assetId}`, { policy })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFullExport() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
await api.post("/sidecar/full-export")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFullImport() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
await api.post("/sidecar/full-import")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
83
k-photos-frontend/hooks/use-storage-admin.ts
Normal file
83
k-photos-frontend/hooks/use-storage-admin.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import api from "@/lib/api"
|
||||||
|
import type { VolumeResponse, LibraryPathResponse } from "@/lib/types"
|
||||||
|
|
||||||
|
export function useVolumes() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["admin", "volumes"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get<VolumeResponse[]>("/storage/volumes")
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRegisterVolume() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: {
|
||||||
|
volume_name: string
|
||||||
|
uri_prefix: string
|
||||||
|
is_writable: boolean
|
||||||
|
}) => {
|
||||||
|
const { data } = await api.post<VolumeResponse>("/storage/volumes", body)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "volumes"] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteVolume() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
await api.delete(`/storage/volumes/${id}`)
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "volumes"] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLibraryPaths() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["admin", "library-paths"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get<LibraryPathResponse[]>(
|
||||||
|
"/storage/library-paths/all",
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRegisterLibraryPath() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: {
|
||||||
|
volume_id: string
|
||||||
|
relative_path: string
|
||||||
|
owner_id: string
|
||||||
|
is_ingest_destination: boolean
|
||||||
|
}) => {
|
||||||
|
const { data } = await api.post<LibraryPathResponse>(
|
||||||
|
"/storage/library-paths",
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
onSuccess: () =>
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin", "library-paths"] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteLibraryPath() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
await api.delete(`/storage/library-paths/${id}`)
|
||||||
|
},
|
||||||
|
onSuccess: () =>
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin", "library-paths"] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
46
k-photos-frontend/hooks/use-timeline.ts
Normal file
46
k-photos-frontend/hooks/use-timeline.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"
|
||||||
|
import api from "@/lib/api"
|
||||||
|
import type { TimelineResponse, DateSummaryResponse } from "@/lib/types"
|
||||||
|
|
||||||
|
const PAGE_SIZE = 40
|
||||||
|
|
||||||
|
export function useTimeline() {
|
||||||
|
const query = useInfiniteQuery({
|
||||||
|
queryKey: ["timeline"],
|
||||||
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
|
const { data } = await api.get<TimelineResponse>("/assets/timeline", {
|
||||||
|
params: { limit: PAGE_SIZE, offset: pageParam },
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: (lastPage, allPages) => {
|
||||||
|
const loaded = allPages.reduce((n, p) => n + p.assets.length, 0)
|
||||||
|
return loaded < lastPage.total ? loaded : undefined
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const assets = query.data?.pages.flatMap((p) => p.assets) ?? []
|
||||||
|
const total = query.data?.pages[0]?.total ?? 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
assets,
|
||||||
|
total,
|
||||||
|
isLoading: query.isLoading,
|
||||||
|
hasMore: query.hasNextPage,
|
||||||
|
loadMore: query.fetchNextPage,
|
||||||
|
isFetchingMore: query.isFetchingNextPage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDateSummary() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["date-summary"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get<DateSummaryResponse>("/assets/date-summary")
|
||||||
|
return data.dates
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
90
k-photos-frontend/hooks/use-upload.ts
Normal file
90
k-photos-frontend/hooks/use-upload.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useCallback } from "react"
|
||||||
|
import { useQueryClient } from "@tanstack/react-query"
|
||||||
|
import api from "@/lib/api"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
interface LibraryPathResponse {
|
||||||
|
id: string
|
||||||
|
is_ingest_destination: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UploadProgress {
|
||||||
|
file: string
|
||||||
|
progress: number
|
||||||
|
done: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpload(onComplete?: () => void) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const [uploads, setUploads] = useState<UploadProgress[]>([])
|
||||||
|
const [isUploading, setIsUploading] = useState(false)
|
||||||
|
|
||||||
|
const upload = useCallback(
|
||||||
|
async (files: File[]) => {
|
||||||
|
setIsUploading(true)
|
||||||
|
const initial = files.map((f) => ({
|
||||||
|
file: f.name,
|
||||||
|
progress: 0,
|
||||||
|
done: false,
|
||||||
|
}))
|
||||||
|
setUploads(initial)
|
||||||
|
|
||||||
|
let targetPathId: string | null = null
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<LibraryPathResponse[]>(
|
||||||
|
"/storage/library-paths",
|
||||||
|
)
|
||||||
|
const dest = data.find((p) => p.is_ingest_destination)
|
||||||
|
targetPathId = dest?.id ?? data[0]?.id ?? null
|
||||||
|
} catch {
|
||||||
|
toast.error("No ingest destination configured")
|
||||||
|
setIsUploading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetPathId) {
|
||||||
|
toast.error("No ingest destination configured")
|
||||||
|
setIsUploading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let succeeded = 0
|
||||||
|
let failed = 0
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
try {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append("file", files[i])
|
||||||
|
form.append("target_path_id", targetPathId)
|
||||||
|
await api.post("/assets/ingest", form, {
|
||||||
|
onUploadProgress: (e) => {
|
||||||
|
const pct = e.total ? Math.round((e.loaded * 100) / e.total) : 0
|
||||||
|
setUploads((prev) =>
|
||||||
|
prev.map((u, j) => (j === i ? { ...u, progress: pct } : u)),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
setUploads((prev) =>
|
||||||
|
prev.map((u, j) =>
|
||||||
|
j === i ? { ...u, progress: 100, done: true } : u,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
succeeded++
|
||||||
|
} catch {
|
||||||
|
failed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (succeeded > 0) toast.success(`Uploaded ${succeeded} file(s)`)
|
||||||
|
if (failed > 0) toast.error(`${failed} upload(s) failed`)
|
||||||
|
|
||||||
|
await qc.invalidateQueries({ queryKey: ["timeline"] })
|
||||||
|
setIsUploading(false)
|
||||||
|
onComplete?.()
|
||||||
|
},
|
||||||
|
[onComplete, qc],
|
||||||
|
)
|
||||||
|
|
||||||
|
return { uploads, isUploading, upload }
|
||||||
|
}
|
||||||
62
k-photos-frontend/lib/api.ts
Normal file
62
k-photos-frontend/lib/api.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import axios from "axios"
|
||||||
|
import { getTokens, setTokens, clearTokens } from "./auth"
|
||||||
|
import type { AuthResponse } from "./types"
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: "/api/v1",
|
||||||
|
})
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const { access } = getTokens()
|
||||||
|
if (access) {
|
||||||
|
config.headers.Authorization = `Bearer ${access}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
let refreshPromise: Promise<string> | null = null
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(res) => res,
|
||||||
|
async (error) => {
|
||||||
|
const original = error.config
|
||||||
|
if (error.response?.status !== 401 || original._retry) {
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
original._retry = true
|
||||||
|
const { refresh } = getTokens()
|
||||||
|
if (!refresh) {
|
||||||
|
clearTokens()
|
||||||
|
window.location.href = "/login"
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!refreshPromise) {
|
||||||
|
refreshPromise = axios
|
||||||
|
.post<AuthResponse>("/api/v1/auth/refresh", {
|
||||||
|
refresh_token: refresh,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
setTokens(res.data.token, res.data.refresh_token)
|
||||||
|
return res.data.token
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
clearTokens()
|
||||||
|
window.location.href = "/login"
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
refreshPromise = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const newToken = await refreshPromise
|
||||||
|
if (!newToken) return Promise.reject(error)
|
||||||
|
|
||||||
|
original.headers.Authorization = `Bearer ${newToken}`
|
||||||
|
return api(original)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export default api
|
||||||
31
k-photos-frontend/lib/auth.ts
Normal file
31
k-photos-frontend/lib/auth.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
const ACCESS_KEY = "k_photos_token"
|
||||||
|
const REFRESH_KEY = "k_photos_refresh"
|
||||||
|
|
||||||
|
export function getTokens() {
|
||||||
|
if (typeof window === "undefined") return { access: null, refresh: null }
|
||||||
|
return {
|
||||||
|
access: localStorage.getItem(ACCESS_KEY),
|
||||||
|
refresh: localStorage.getItem(REFRESH_KEY),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTokens(access: string, refresh: string) {
|
||||||
|
localStorage.setItem(ACCESS_KEY, access)
|
||||||
|
localStorage.setItem(REFRESH_KEY, refresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearTokens() {
|
||||||
|
localStorage.removeItem(ACCESS_KEY)
|
||||||
|
localStorage.removeItem(REFRESH_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRoleFromToken(): string | null {
|
||||||
|
const { access } = getTokens()
|
||||||
|
if (!access) return null
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(access.split(".")[1]))
|
||||||
|
return payload.role ?? null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
35
k-photos-frontend/lib/timeline.ts
Normal file
35
k-photos-frontend/lib/timeline.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { format, parseISO } from "date-fns"
|
||||||
|
import type { AssetResponse } from "./types"
|
||||||
|
|
||||||
|
export interface DateGroup {
|
||||||
|
date: string
|
||||||
|
label: string
|
||||||
|
assets: AssetResponse[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPhotoDate(asset: AssetResponse): Date {
|
||||||
|
const dto = asset.metadata?.DateTimeOriginal as string | undefined
|
||||||
|
if (dto) {
|
||||||
|
const parsed = new Date(dto.replace(" ", "T"))
|
||||||
|
if (!isNaN(parsed.getTime())) return parsed
|
||||||
|
}
|
||||||
|
return parseISO(asset.created_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupByDate(assets: AssetResponse[]): DateGroup[] {
|
||||||
|
const map = new Map<string, AssetResponse[]>()
|
||||||
|
|
||||||
|
for (const asset of assets) {
|
||||||
|
const d = getPhotoDate(asset)
|
||||||
|
const key = format(d, "yyyy-MM-dd")
|
||||||
|
const group = map.get(key)
|
||||||
|
if (group) group.push(asset)
|
||||||
|
else map.set(key, [asset])
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(map.entries()).map(([date, assets]) => ({
|
||||||
|
date,
|
||||||
|
label: format(parseISO(date), "MMMM d, yyyy"),
|
||||||
|
assets,
|
||||||
|
}))
|
||||||
|
}
|
||||||
158
k-photos-frontend/lib/types.ts
Normal file
158
k-photos-frontend/lib/types.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
export interface UserResponse {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
role: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
token: string
|
||||||
|
refresh_token: string
|
||||||
|
user: UserResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssetResponse {
|
||||||
|
id: string
|
||||||
|
asset_type: string
|
||||||
|
mime_type: string
|
||||||
|
file_size: number
|
||||||
|
is_processed: boolean
|
||||||
|
created_at: string
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineResponse {
|
||||||
|
assets: AssetResponse[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DateCountEntry {
|
||||||
|
date: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DateSummaryResponse {
|
||||||
|
dates: DateCountEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlbumResponse {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
creator_id: string
|
||||||
|
asset_count: number
|
||||||
|
asset_ids: string[]
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IngestResponse {
|
||||||
|
asset: AssetResponse
|
||||||
|
session_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterRequest {
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAlbumRequest {
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateAlbumRequest {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Storage Admin ---
|
||||||
|
|
||||||
|
export interface VolumeResponse {
|
||||||
|
id: string
|
||||||
|
volume_name: string
|
||||||
|
uri_prefix: string
|
||||||
|
is_writable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LibraryPathResponse {
|
||||||
|
id: string
|
||||||
|
volume_id: string
|
||||||
|
relative_path: string
|
||||||
|
is_ingest_destination: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Processing ---
|
||||||
|
|
||||||
|
export interface JobResponse {
|
||||||
|
job_id: string
|
||||||
|
job_type: string
|
||||||
|
status: string
|
||||||
|
priority: number
|
||||||
|
created_at: string
|
||||||
|
error_message: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobListResponse {
|
||||||
|
jobs: JobResponse[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchProgressResponse {
|
||||||
|
batch_id: string
|
||||||
|
batch_type: string
|
||||||
|
total: number
|
||||||
|
completed: number
|
||||||
|
failed: number
|
||||||
|
status: string
|
||||||
|
jobs: JobResponse[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginResponse {
|
||||||
|
plugin_id: string
|
||||||
|
name: string
|
||||||
|
plugin_type: string
|
||||||
|
is_enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PipelineResponse {
|
||||||
|
pipeline_id: string
|
||||||
|
trigger_event: string
|
||||||
|
steps_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sidecars ---
|
||||||
|
|
||||||
|
export interface SidecarExportResponse {
|
||||||
|
asset_id: string
|
||||||
|
status: string
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetectChangesResponse {
|
||||||
|
changed_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidecarImportResponse {
|
||||||
|
asset_id: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Duplicates ---
|
||||||
|
|
||||||
|
export interface DuplicateGroupResponse {
|
||||||
|
group_id: string
|
||||||
|
detection_method: string
|
||||||
|
status: string
|
||||||
|
candidates: DuplicateCandidateResponse[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DuplicateCandidateResponse {
|
||||||
|
asset_id: string
|
||||||
|
similarity_score: number
|
||||||
|
}
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
import type { NextConfig } from "next"
|
import type { NextConfig } from "next"
|
||||||
|
|
||||||
const nextConfig: NextConfig = {}
|
const nextConfig: NextConfig = {
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/api/v1/:path*",
|
||||||
|
destination: "http://localhost:8000/api/v1/:path*",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
export default nextConfig
|
export default nextConfig
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user