feat: frontend MVP — auth, timeline, upload, albums, admin, image viewer

Backend:
- user roles (DB + JWT + first-user-is-admin)
- volume-aware file resolver (multi-volume asset serving)
- directory scanner uses volume URI directly
- date-summary endpoint (capture date from EXIF)
- timeline ordered by capture date
- list endpoints: volumes, plugins, pipelines, library paths
- delete endpoints: volumes, library paths
- configurable upload body limit (MAX_UPLOAD_BYTES)

Frontend:
- auth: login/register, token refresh, role-based admin gate
- timeline: date-grouped grid, infinite scroll, date scrubber
- image viewer: fullscreen zoom/pan/pinch, metadata sidebar
- upload: drag-drop, sequential upload, progress tracking
- albums: create, add/remove photos, asset picker dialog
- admin: storage (import library), jobs (pagination, error details),
  plugins (list + toggle), pipelines, sidecars, duplicates
- multi-select mode with add-to-album action
- TanStack Query for all data fetching
This commit is contained in:
2026-06-01 01:35:43 +02:00
parent 49f77a78b9
commit 957737ac9b
101 changed files with 4679 additions and 109 deletions

View File

@@ -34,6 +34,11 @@ CORS_ALLOWED_ORIGINS=http://localhost:8000,http://localhost:5173
# ============================================================================
STORAGE_PATH=./data/media
# ============================================================================
# Uploads (default 256 MiB)
# ============================================================================
# MAX_UPLOAD_BYTES=268435456
# ============================================================================
# Logging
# ============================================================================

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user';

View File

@@ -0,0 +1 @@
UPDATE plugins SET name = 'scan_directory' WHERE plugin_id = 'a0000000-0000-4000-8000-000000000005';

View File

@@ -189,10 +189,16 @@ impl AssetRepository for PostgresAssetRepository {
offset: u32,
) -> Result<Vec<Asset>, DomainError> {
let rows = sqlx::query_as::<_, AssetRow>(
"SELECT asset_id, volume_id, relative_path, checksum, asset_type, mime_type,
file_size, is_processed, owner_user_id, created_at
FROM assets WHERE owner_user_id = $1
ORDER BY created_at DESC
"SELECT a.asset_id, a.volume_id, a.relative_path, a.checksum, a.asset_type, a.mime_type,
a.file_size, a.is_processed, a.owner_user_id, a.created_at
FROM assets a
LEFT JOIN asset_metadata am
ON am.asset_id = a.asset_id AND am.metadata_source = 'exif_extracted'
WHERE a.owner_user_id = $1
ORDER BY COALESCE(
(am.data->>'DateTimeOriginal')::timestamptz,
a.created_at
) DESC
LIMIT $2 OFFSET $3",
)
.bind(*owner_id.as_uuid())
@@ -296,6 +302,30 @@ impl AssetRepository for PostgresAssetRepository {
Ok(count as u64)
}
async fn date_summary(
&self,
owner_id: &SystemId,
) -> Result<Vec<(chrono::NaiveDate, u64)>, DomainError> {
let rows: Vec<(chrono::NaiveDate, i64)> = sqlx::query_as(
"SELECT COALESCE(
(am.data->>'DateTimeOriginal')::timestamptz,
a.created_at
)::date AS day,
COUNT(*) AS cnt
FROM assets a
LEFT JOIN asset_metadata am
ON am.asset_id = a.asset_id AND am.metadata_source = 'exif_extracted'
WHERE a.owner_user_id = $1
GROUP BY day ORDER BY day DESC",
)
.bind(*owner_id.as_uuid())
.fetch_all(&self.pool)
.await
.map_pg()?;
Ok(rows.into_iter().map(|(d, c)| (d, c as u64)).collect())
}
async fn save(&self, asset: &Asset) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO assets (asset_id, volume_id, relative_path, checksum, asset_type,

View File

@@ -15,6 +15,7 @@ struct UserRow {
username: String,
email: String,
password_hash: String,
role: String,
created_at: DateTime<Utc>,
}
@@ -26,6 +27,7 @@ impl TryFrom<UserRow> for domain::entities::User {
username: r.username,
email: Email::new(r.email)?,
password_hash: PasswordHash::from_hash(r.password_hash),
role: r.role,
created_at: r.created_at,
})
}
@@ -40,7 +42,7 @@ impl UserRepository for PostgresUserRepository {
id: &SystemId,
) -> Result<Option<domain::entities::User>, DomainError> {
let row = sqlx::query_as::<_, UserRow>(
"SELECT id, username, email, password_hash, created_at FROM users WHERE id = $1",
"SELECT id, username, email, password_hash, role, created_at FROM users WHERE id = $1",
)
.bind(*id.as_uuid())
.fetch_optional(&self.pool)
@@ -55,7 +57,7 @@ impl UserRepository for PostgresUserRepository {
email: &Email,
) -> Result<Option<domain::entities::User>, DomainError> {
let row = sqlx::query_as::<_, UserRow>(
"SELECT id, username, email, password_hash, created_at FROM users WHERE email = $1",
"SELECT id, username, email, password_hash, role, created_at FROM users WHERE email = $1",
)
.bind(email.as_str())
.fetch_optional(&self.pool)
@@ -70,7 +72,7 @@ impl UserRepository for PostgresUserRepository {
username: &str,
) -> Result<Option<domain::entities::User>, DomainError> {
let row = sqlx::query_as::<_, UserRow>(
"SELECT id, username, email, password_hash, created_at FROM users WHERE username = $1",
"SELECT id, username, email, password_hash, role, created_at FROM users WHERE username = $1",
)
.bind(username)
.fetch_optional(&self.pool)
@@ -82,18 +84,20 @@ impl UserRepository for PostgresUserRepository {
async fn save(&self, user: &domain::entities::User) -> Result<(), DomainError> {
sqlx::query_as::<_, UserRow>(
"INSERT INTO users (id, username, email, password_hash, created_at)
VALUES ($1, $2, $3, $4, $5)
"INSERT INTO users (id, username, email, password_hash, role, created_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (id) DO UPDATE SET
username = EXCLUDED.username,
email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash
RETURNING id, username, email, password_hash, created_at",
password_hash = EXCLUDED.password_hash,
role = EXCLUDED.role
RETURNING id, username, email, password_hash, role, created_at",
)
.bind(*user.id.as_uuid())
.bind(&user.username)
.bind(user.email.as_str())
.bind(user.password_hash.as_str())
.bind(&user.role)
.bind(user.created_at)
.fetch_one(&self.pool)
.await
@@ -109,6 +113,14 @@ impl UserRepository for PostgresUserRepository {
.map_pg()?;
Ok(())
}
async fn count(&self) -> Result<u64, DomainError> {
let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
.fetch_one(&self.pool)
.await
.map_pg()?;
Ok(count as u64)
}
}
// --- PostgresRefreshTokenRepository ---

View File

@@ -405,6 +405,17 @@ impl PluginRepository for PostgresPluginRepository {
Ok(row.map(Into::into))
}
async fn find_all(&self) -> Result<Vec<Plugin>, DomainError> {
let rows = sqlx::query_as::<_, PluginRow>(
"SELECT plugin_id, name, plugin_type, is_enabled, configuration FROM plugins",
)
.fetch_all(&self.pool)
.await
.map_pg()?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn find_enabled(&self) -> Result<Vec<Plugin>, DomainError> {
let rows = sqlx::query_as::<_, PluginRow>(
"SELECT plugin_id, name, plugin_type, is_enabled, configuration
@@ -521,6 +532,17 @@ impl PipelineRepository for PostgresPipelineRepository {
Ok(row.map(Into::into))
}
async fn find_all(&self) -> Result<Vec<ProcessingPipeline>, DomainError> {
let rows = sqlx::query_as::<_, PipelineRow>(
"SELECT pipeline_id, trigger_event, steps FROM processing_pipelines",
)
.fetch_all(&self.pool)
.await
.map_pg()?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn find_by_trigger(&self, event: &str) -> Result<Vec<ProcessingPipeline>, DomainError> {
let rows = sqlx::query_as::<_, PipelineRow>(
"SELECT pipeline_id, trigger_event, steps

View File

@@ -160,6 +160,18 @@ impl LibraryPathRepository for PostgresLibraryPathRepository {
Ok(row.map(Into::into))
}
async fn find_all(&self) -> Result<Vec<LibraryPath>, DomainError> {
let rows = sqlx::query_as::<_, LibraryPathRow>(
"SELECT path_id, volume_id, relative_path, is_ingest_destination, ownership_policy, designated_owner_id
FROM library_paths",
)
.fetch_all(&self.pool)
.await
.map_pg()?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn find_by_volume(&self, volume_id: &SystemId) -> Result<Vec<LibraryPath>, DomainError> {
let rows = sqlx::query_as::<_, LibraryPathRow>(
"SELECT path_id, volume_id, relative_path, is_ingest_destination, ownership_policy, designated_owner_id
@@ -180,7 +192,7 @@ impl LibraryPathRepository for PostgresLibraryPathRepository {
let rows = sqlx::query_as::<_, LibraryPathRow>(
"SELECT path_id, volume_id, relative_path, is_ingest_destination, ownership_policy, designated_owner_id
FROM library_paths
WHERE is_ingest_destination = true AND designated_owner_id = $1",
WHERE is_ingest_destination = true AND (designated_owner_id = $1 OR designated_owner_id IS NULL)",
)
.bind(*owner_id.as_uuid())
.fetch_all(&self.pool)

View File

@@ -1,7 +1,9 @@
pub mod adapter;
pub mod config;
pub mod local_file_storage;
pub mod volume_resolver;
pub use adapter::ObjectStorageAdapter;
pub use config::{StorageConfig, build_store};
pub use local_file_storage::LocalFileStorage;
pub use volume_resolver::LocalVolumeFileResolver;

View 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))
}
}

View File

@@ -6,6 +6,7 @@ pub struct UserResponse {
pub id: Uuid,
pub username: String,
pub email: String,
pub role: String,
pub created_at: DateTime<Utc>,
}
@@ -22,6 +23,7 @@ impl UserResponse {
id: *user.id.as_uuid(),
username: user.username.clone(),
email: user.email.to_string(),
role: user.role.clone(),
created_at: user.created_at,
}
}
@@ -34,6 +36,7 @@ pub struct AlbumResponse {
pub description: String,
pub creator_id: Uuid,
pub asset_count: usize,
pub asset_ids: Vec<Uuid>,
pub created_at: DateTime<Utc>,
}
@@ -45,6 +48,7 @@ impl AlbumResponse {
description: album.description.clone(),
creator_id: *album.creator_user_id.as_uuid(),
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(),
}
}
@@ -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)]
pub struct TimelineResponse {
pub assets: Vec<AssetResponse>,
@@ -349,6 +364,7 @@ pub struct JobResponse {
pub status: String,
pub priority: u32,
pub created_at: DateTime<Utc>,
pub error_message: Option<String>,
}
impl JobResponse {
@@ -359,6 +375,7 @@ impl JobResponse {
status: format!("{:?}", job.status),
priority: job.priority,
created_at: *job.created_at.as_datetime(),
error_message: job.error_message.clone(),
}
}
}

View File

@@ -13,6 +13,7 @@ pub use commands::resolve_duplicate::{
};
pub use commands::update_metadata::{UpdateMetadataCommand, UpdateMetadataHandler};
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_timeline::{GetTimelineHandler, GetTimelineQuery, TimelineResult};
pub use queries::list_stacks::{ListStacksHandler, ListStacksQuery};

View 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())
}
}

View File

@@ -1,4 +1,5 @@
pub mod get_asset;
pub mod get_date_summary;
pub mod get_stack;
pub mod get_timeline;
pub mod list_stacks;

View File

@@ -1,6 +1,6 @@
use domain::{
errors::DomainError,
ports::{AssetRepository, DataStream, FileStoragePort},
ports::{AssetRepository, DataStream, VolumeFileResolver},
value_objects::SystemId,
};
use std::sync::Arc;
@@ -20,17 +20,17 @@ pub struct AssetFileResult {
pub struct ReadAssetFileHandler {
asset_repo: Arc<dyn AssetRepository>,
file_storage: Arc<dyn FileStoragePort>,
volume_resolver: Arc<dyn VolumeFileResolver>,
}
impl ReadAssetFileHandler {
pub fn new(
asset_repo: Arc<dyn AssetRepository>,
file_storage: Arc<dyn FileStoragePort>,
volume_resolver: Arc<dyn VolumeFileResolver>,
) -> Self {
Self {
asset_repo,
file_storage,
volume_resolver,
}
}
@@ -46,8 +46,11 @@ impl ReadAssetFileHandler {
}
let (stream, size) = self
.file_storage
.open_file(&asset.source_reference.relative_path)
.volume_resolver
.open_by_volume(
&asset.source_reference.volume_id,
&asset.source_reference.relative_path,
)
.await?;
let filename = asset

View File

@@ -134,6 +134,13 @@ impl AssetRepository for VisibilityFilteredAssetRepository {
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> {
self.inner.save(asset).await
}

View File

@@ -52,7 +52,7 @@ impl LoginUserHandler {
if !valid {
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?;
Ok((user, access_token, raw_refresh))
}

View File

@@ -1,7 +1,7 @@
use super::login_user::generate_refresh_token;
use domain::{
errors::DomainError,
ports::{RefreshTokenRepository, TokenIssuer},
ports::{RefreshTokenRepository, TokenIssuer, UserRepository},
};
use sha2::{Digest, Sha256};
use std::sync::Arc;
@@ -13,16 +13,19 @@ pub struct RefreshTokenCommand {
pub struct RefreshTokenHandler {
refresh_repo: Arc<dyn RefreshTokenRepository>,
user_repo: Arc<dyn UserRepository>,
issuer: Arc<dyn TokenIssuer>,
}
impl RefreshTokenHandler {
pub fn new(
refresh_repo: Arc<dyn RefreshTokenRepository>,
user_repo: Arc<dyn UserRepository>,
issuer: Arc<dyn TokenIssuer>,
) -> Self {
Self {
refresh_repo,
user_repo,
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?;
let access_token = self.issuer.issue(&token.user_id, "user").await?;
let (raw_refresh, _) = generate_refresh_token(&self.refresh_repo, &token.user_id).await?;
let access_token = self.issuer.issue(&user.id, &user.role).await?;
let (raw_refresh, _) = generate_refresh_token(&self.refresh_repo, &user.id).await?;
Ok((access_token, raw_refresh))
}

View File

@@ -53,7 +53,11 @@ impl RegisterUserHandler {
)));
}
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?;
Ok(user)
}

View File

@@ -12,6 +12,8 @@ pub use commands::manage_plugin::{ManagePluginCommand, ManagePluginHandler, Plug
pub use commands::process_next_job::{ProcessNextJobCommand, ProcessNextJobHandler};
pub use commands::start_job::{StartJobCommand, StartJobHandler};
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::{
BatchProgress, ReportBatchProgressHandler, ReportBatchProgressQuery,
};

View 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
}
}

View 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
}
}

View File

@@ -1,2 +1,4 @@
pub mod list_jobs;
pub mod list_pipelines;
pub mod list_plugins;
pub mod report_batch_progress;

View File

@@ -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
}
}

View 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
}
}

View File

@@ -1,3 +1,5 @@
pub mod delete_library_path;
pub mod delete_volume;
pub mod ingest_asset;
pub mod register_library_path;
pub mod register_volume;

View File

@@ -1,7 +1,12 @@
pub mod commands;
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::register_library_path::{RegisterLibraryPathCommand, RegisterLibraryPathHandler};
pub use commands::register_volume::{RegisterVolumeCommand, RegisterVolumeHandler};
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;

View File

@@ -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
}
}

View 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
}
}

View 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
}
}

View File

@@ -1 +1,4 @@
pub mod check_quota;
pub mod list_all_library_paths;
pub mod list_ingest_paths;
pub mod list_volumes;

View File

@@ -103,6 +103,10 @@ impl UserRepository for InMemoryUserRepository {
self.users.lock().await.remove(&id.to_string());
Ok(())
}
async fn count(&self) -> Result<u64, DomainError> {
Ok(self.users.lock().await.len() as u64)
}
}
in_memory_repo!(InMemoryAssetRepository, Asset);
@@ -173,6 +177,21 @@ impl AssetRepository for InMemoryAssetRepository {
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> {
self.data
.lock()
@@ -385,6 +404,10 @@ impl LibraryPathRepository for InMemoryLibraryPathRepository {
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> {
Ok(self
.data
@@ -918,6 +941,10 @@ impl PluginRepository for InMemoryPluginRepository {
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> {
Ok(self
.data
@@ -946,6 +973,10 @@ impl PipelineRepository for InMemoryPipelineRepository {
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> {
Ok(self
.data

View File

@@ -6,6 +6,7 @@ pub struct Config {
pub nats_url: String,
pub jwt_secret: String,
pub cors_allowed_origins: Vec<String>,
pub max_upload_bytes: usize,
}
impl Config {
@@ -26,6 +27,10 @@ impl Config {
.split(',')
.map(|s| s.trim().to_string())
.collect(),
max_upload_bytes: std::env::var("MAX_UPLOAD_BYTES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(256 * 1024 * 1024),
}
}
}

View File

@@ -1,5 +1,6 @@
use anyhow::Result;
use axum::Router;
use axum::extract::DefaultBodyLimit;
use axum::http::HeaderValue;
use std::sync::Arc;
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 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
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)
.with_state(state)
.layer(DefaultBodyLimit::max(config.max_upload_bytes))
.layer(TraceLayer::new_for_http())
.layer(cors))
}

View File

@@ -5,10 +5,12 @@ use adapters_postgres::{
PostgresDerivativeRepository, PostgresDuplicateRepository, PostgresIngestTransaction,
PostgresSidecarRepository,
};
use adapters_storage::LocalFileStorage;
use adapters_storage::LocalVolumeFileResolver;
use domain::ports::FileStoragePort;
use application::catalog::{
CreateStackHandler, DeleteAssetHandler, DeleteStackHandler, DetectLivePhotosHandler,
GetAssetHandler, GetStackHandler, GetTimelineHandler, ListDuplicatesHandler,
GetAssetHandler, GetDateSummaryHandler, GetStackHandler, GetTimelineHandler,
ListDuplicatesHandler,
ReadAssetFileHandler, ReadDerivativeHandler, RegisterAssetHandler, ResolveDuplicateHandler,
SearchAssetsHandler, UpdateMetadataHandler,
};
@@ -21,7 +23,7 @@ use super::storage::StorageRepos;
pub fn build(
pool: &PgPool,
storage_repos: &StorageRepos,
file_storage: Arc<LocalFileStorage>,
file_storage: Arc<dyn FileStoragePort>,
event_publisher: Arc<dyn EventPublisher>,
) -> CatalogHandlers {
let asset_repo = Arc::new(PostgresAssetRepository::new(pool.clone()));
@@ -49,15 +51,20 @@ pub fn build(
metadata_repo.clone(),
));
let get_date_summary = Arc::new(GetDateSummaryHandler::new(asset_repo.clone()));
let update_metadata = Arc::new(UpdateMetadataHandler::new(
asset_repo.clone(),
metadata_repo.clone(),
event_publisher.clone(),
));
let volume_resolver = Arc::new(LocalVolumeFileResolver::new(
storage_repos.volume_repo.clone(),
));
let read_asset_file = Arc::new(ReadAssetFileHandler::new(
asset_repo.clone(),
file_storage.clone(),
volume_resolver,
));
let read_derivative = Arc::new(ReadDerivativeHandler::new(
@@ -103,6 +110,7 @@ pub fn build(
ingest_asset,
get_asset,
get_timeline,
get_date_summary,
update_metadata,
read_asset_file,
read_derivative,

View File

@@ -26,9 +26,10 @@ pub fn build(pool: &PgPool, jwt_secret: &str) -> IdentityServices {
issuer.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(
refresh_repo.clone(),
user_repo,
issuer.clone(),
));
let logout = Arc::new(LogoutHandler::new(refresh_repo.clone()));

View File

@@ -6,7 +6,8 @@ use adapters_postgres::{
};
use application::processing::{
CompleteJobHandler, ConfigurePipelineHandler, EnqueueJobHandler, FailJobHandler,
ListJobsHandler, ManagePluginHandler, ReportBatchProgressHandler, StartJobHandler,
ListJobsHandler, ListPipelinesHandler, ListPluginsHandler, ManagePluginHandler,
ReportBatchProgressHandler, StartJobHandler,
};
use domain::ports::EventPublisher;
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 batch_progress = Arc::new(ReportBatchProgressHandler::new(batch_repo, job_repo));
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));
ProcessingHandlers {
@@ -45,6 +48,8 @@ pub fn build(pool: &PgPool, event_publisher: Arc<dyn EventPublisher>) -> Process
list_jobs,
batch_progress,
manage_plugin,
list_plugins,
configure_pipeline,
list_pipelines,
}
}

View File

@@ -4,12 +4,17 @@ use adapters_postgres::{
PgPool, PostgresLibraryPathRepository, PostgresQuotaRepository,
PostgresStorageVolumeRepository, PostgresUsageLedgerRepository,
};
use application::storage::{CheckQuotaHandler, RegisterLibraryPathHandler, RegisterVolumeHandler};
use application::storage::{
CheckQuotaHandler, DeleteLibraryPathHandler, DeleteVolumeHandler, ListAllLibraryPathsHandler,
ListIngestPathsHandler, ListVolumesHandler, RegisterLibraryPathHandler,
RegisterVolumeHandler,
};
use presentation::state::StorageHandlers;
/// Shared storage repos needed by other bounded contexts (catalog ingest, etc.).
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) {
@@ -18,20 +23,33 @@ pub fn build(pool: &PgPool) -> (StorageRepos, StorageHandlers) {
let quota_repo = Arc::new(PostgresQuotaRepository::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 delete_volume = Arc::new(DeleteVolumeHandler::new(volume_repo.clone()));
let register_library_path = Arc::new(RegisterLibraryPathHandler::new(
volume_repo,
volume_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 handlers = StorageHandlers {
register_volume,
delete_volume,
list_volumes,
register_library_path,
list_ingest_paths,
list_all_library_paths,
delete_library_path,
check_quota,
};
let repos = StorageRepos { path_repo };
let repos = StorageRepos {
path_repo,
volume_repo,
};
(repos, handlers)
}

View File

@@ -32,6 +32,10 @@ pub trait AssetRepository: Send + Sync {
owner_id: &SystemId,
filters: &AssetFilters,
) -> 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 delete(&self, id: &SystemId) -> Result<(), DomainError>;
}

View File

@@ -126,6 +126,7 @@ pub struct User {
pub username: String,
pub email: Email,
pub password_hash: PasswordHash,
pub role: String,
pub created_at: DateTime<Utc>,
}
@@ -136,9 +137,14 @@ impl User {
username: username.into(),
email,
password_hash,
role: "user".to_string(),
created_at: Utc::now(),
}
}
pub fn is_admin(&self) -> bool {
self.role == "admin"
}
}
// --- RefreshToken ---

View File

@@ -12,6 +12,7 @@ pub trait UserRepository: Send + Sync {
async fn find_by_username(&self, username: &str) -> Result<Option<User>, DomainError>;
async fn save(&self, user: &User) -> Result<(), DomainError>;
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
async fn count(&self) -> Result<u64, DomainError>;
}
// --- RoleRepository ---

View File

@@ -34,6 +34,7 @@ pub trait JobBatchRepository: Send + Sync {
#[async_trait]
pub trait PluginRepository: Send + Sync {
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 save(&self, plugin: &Plugin) -> Result<(), DomainError>;
}
@@ -43,6 +44,7 @@ pub trait PluginRepository: Send + Sync {
#[async_trait]
pub trait PipelineRepository: Send + Sync {
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 save(&self, pipeline: &ProcessingPipeline) -> Result<(), DomainError>;
}

View File

@@ -23,6 +23,7 @@ pub trait StorageVolumeRepository: Send + Sync {
#[async_trait]
pub trait LibraryPathRepository: Send + Sync {
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_ingest_destinations(
&self,
@@ -84,6 +85,23 @@ pub trait IngestTransaction: Send + Sync {
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 ---
#[derive(Debug, Clone)]

View File

@@ -7,11 +7,14 @@ use crate::{
};
use api_types::{
requests::{RegisterAssetRequest, TagAssetRequest},
responses::{AssetResponse, IngestResponse, TagResponse, TimelineResponse},
responses::{
AssetResponse, DateCountEntry, DateSummaryResponse, IngestResponse, TagResponse,
TimelineResponse,
},
};
use application::{
catalog::{
DeleteAssetCommand, GetAssetQuery, GetTimelineQuery, ReadAssetFileQuery,
DeleteAssetCommand, GetAssetQuery, GetDateSummaryQuery, GetTimelineQuery, ReadAssetFileQuery,
ReadDerivativeQuery, RegisterAssetCommand, SearchAssetsQuery, UpdateMetadataCommand,
},
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(
put, path = "/api/v1/assets/{id}/metadata",
request_body = api_types::requests::UpdateMetadataRequest,

View File

@@ -34,7 +34,7 @@ pub async fn register(
let user = state.identity.register.execute(cmd).await?;
let token = state
.token_issuer
.issue(&user.id, "user")
.issue(&user.id, &user.role)
.await
.map_err(AppError::from)?;
let (refresh_token, _) =

View File

@@ -198,6 +198,15 @@ pub async fn batch_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(
post, path = "/api/v1/plugins",
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(
post, path = "/api/v1/pipelines",
request_body = ConfigurePipelineRequest,

View File

@@ -3,14 +3,35 @@ use api_types::{
requests::{CheckQuotaParams, RegisterLibraryPathRequest, RegisterVolumeRequest},
responses::{LibraryPathResponse, QuotaCheckResponse, VolumeResponse},
};
use application::storage::{CheckQuotaQuery, RegisterLibraryPathCommand, RegisterVolumeCommand};
use application::storage::{
CheckQuotaQuery, ListIngestPathsQuery, RegisterLibraryPathCommand, RegisterVolumeCommand,
};
use axum::{
Json,
extract::{Query, State},
extract::{Path, Query, State},
http::StatusCode,
};
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(
post, path = "/api/v1/storage/volumes",
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_AMOUNT: u64 = 0;

View File

@@ -13,6 +13,7 @@ pub fn routes() -> Router<AppState> {
.route("/assets/ingest", post(assets::ingest))
.route("/assets/register", post(assets::register_asset))
.route("/assets/timeline", get(assets::timeline))
.route("/assets/date-summary", get(assets::date_summary))
.route(
"/assets/{id}",
get(assets::get_asset).delete(assets::delete_asset),

View File

@@ -14,6 +14,6 @@ pub fn routes() -> Router<AppState> {
.route("/jobs/{id}/complete", post(processing::complete_job))
.route("/jobs/{id}/fail", post(processing::fail_job))
.route("/jobs/batches/{id}", get(processing::batch_progress))
.route("/plugins", post(processing::manage_plugin))
.route("/pipelines", post(processing::configure_pipeline))
.route("/plugins", get(processing::list_plugins).post(processing::manage_plugin))
.route("/pipelines", get(processing::list_pipelines).post(processing::configure_pipeline))
}

View File

@@ -1,15 +1,27 @@
use crate::{handlers::storage, state::AppState};
use axum::{
Router,
routing::{get, post},
routing::{delete, get, post},
};
pub fn routes() -> Router<AppState> {
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(
"/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))
}

View File

@@ -3,9 +3,10 @@ use std::sync::Arc;
use application::{
catalog::{
CreateStackHandler, DeleteAssetHandler, DeleteStackHandler, DetectLivePhotosHandler,
GetAssetHandler, GetStackHandler, GetTimelineHandler, ListDuplicatesHandler,
ListStacksHandler, ReadAssetFileHandler, ReadDerivativeHandler, RegisterAssetHandler,
ResolveDuplicateHandler, SearchAssetsHandler, UpdateMetadataHandler,
GetAssetHandler, GetDateSummaryHandler, GetStackHandler, GetTimelineHandler,
ListDuplicatesHandler, ListStacksHandler, ReadAssetFileHandler, ReadDerivativeHandler,
RegisterAssetHandler, ResolveDuplicateHandler, SearchAssetsHandler,
UpdateMetadataHandler,
},
identity::{
GetProfileHandler, LoginUserHandler, LogoutHandler, RefreshTokenHandler,
@@ -17,7 +18,8 @@ use application::{
},
processing::{
CompleteJobHandler, ConfigurePipelineHandler, EnqueueJobHandler, FailJobHandler,
ListJobsHandler, ManagePluginHandler, ReportBatchProgressHandler, StartJobHandler,
ListJobsHandler, ListPipelinesHandler, ListPluginsHandler, ManagePluginHandler,
ReportBatchProgressHandler, StartJobHandler,
},
sharing::{
AccessSharedResourceHandler, GenerateShareLinkHandler, RevokeShareHandler,
@@ -28,7 +30,9 @@ use application::{
ImportSidecarHandler, ResolveConflictHandler,
},
storage::{
CheckQuotaHandler, IngestAssetHandler, RegisterLibraryPathHandler, RegisterVolumeHandler,
CheckQuotaHandler, DeleteLibraryPathHandler, DeleteVolumeHandler, IngestAssetHandler,
ListAllLibraryPathsHandler, ListIngestPathsHandler, ListVolumesHandler,
RegisterLibraryPathHandler, RegisterVolumeHandler,
},
};
use domain::ports::{RefreshTokenRepository, TokenIssuer};
@@ -48,6 +52,7 @@ pub struct CatalogHandlers {
pub ingest_asset: Arc<IngestAssetHandler>,
pub get_asset: Arc<GetAssetHandler>,
pub get_timeline: Arc<GetTimelineHandler>,
pub get_date_summary: Arc<GetDateSummaryHandler>,
pub update_metadata: Arc<UpdateMetadataHandler>,
pub read_asset_file: Arc<ReadAssetFileHandler>,
pub read_derivative: Arc<ReadDerivativeHandler>,
@@ -76,7 +81,12 @@ pub struct OrganizationHandlers {
#[derive(Clone)]
pub struct StorageHandlers {
pub register_volume: Arc<RegisterVolumeHandler>,
pub delete_volume: Arc<DeleteVolumeHandler>,
pub list_volumes: Arc<ListVolumesHandler>,
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>,
}
@@ -107,7 +117,9 @@ pub struct ProcessingHandlers {
pub list_jobs: Arc<ListJobsHandler>,
pub batch_progress: Arc<ReportBatchProgressHandler>,
pub manage_plugin: Arc<ManagePluginHandler>,
pub list_plugins: Arc<ListPluginsHandler>,
pub configure_pipeline: Arc<ConfigurePipelineHandler>,
pub list_pipelines: Arc<ListPipelinesHandler>,
}
#[derive(Clone)]

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>
)
}

View 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>
)
}

View 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}</>
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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&apos;t have an account?{" "}
<Link href="/register" className="underline hover:text-foreground">
Register
</Link>
</p>
</form>
</CardContent>
</Card>
)
}

View 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>
)
}

View File

@@ -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 { 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({
subsets: ["latin"],
@@ -20,10 +23,22 @@ export default function RootLayout({
<html
lang="en"
suppressHydrationWarning
className={cn("antialiased", fontMono.variable, "font-sans", inter.variable)}
className={cn(
"antialiased",
fontMono.variable,
"font-sans",
inter.variable,
)}
>
<body>
<ThemeProvider>{children}</ThemeProvider>
<ThemeProvider>
<QueryProvider>
<AuthProvider>
{children}
<Toaster />
</AuthProvider>
</QueryProvider>
</ThemeProvider>
</body>
</html>
)

View File

@@ -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&apos;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>
)
}

View File

@@ -5,6 +5,7 @@
"name": "next-app",
"dependencies": {
"@base-ui/react": "^1.5.0",
"@tanstack/react-query": "^5.100.14",
"axios": "^1.16.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -20,6 +21,7 @@
"react-day-picker": "^10.0.1",
"react-dom": "19.2.4",
"react-resizable-panels": "^4.11.2",
"react-zoom-pan-pinch": "^4.0.3",
"recharts": "3.8.0",
"shadcn": "^4.9.0",
"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=="],
"@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=="],
"@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-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=="],
"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=="],

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
}

View File

@@ -87,6 +87,7 @@ function Calendar({
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
defaultClassNames.caption_label
),
// @ts-expect-error react-day-picker v10 type mismatch
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(

View File

@@ -61,7 +61,7 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
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
)}
{...props}

View 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>
)
}

View 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,
}
}

View 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
}

View 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"] }),
})
}

View 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,
})
}

View 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"] }),
})
}

View 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"] }),
})
}

View 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")
},
})
}

View 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"] }),
})
}

View 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
},
})
}

View 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 }
}

View 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

View 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
}
}

View 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,
}))
}

View 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
}

View File

@@ -1,5 +1,14 @@
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

Some files were not shown because too many files have changed in this diff Show More