feat: safe deletion, album/asset delete, trash, README update

- volume-aware deletion: read-only volumes remove DB only, writable
  volumes soft-delete to trash with configurable grace period
- trash page with restore, worker purge sweep (TRASH_RETENTION_DAYS)
- album delete endpoint + sidebar trash icon
- asset delete from timeline selection toolbar
- all listing queries exclude trashed assets (deleted_at IS NULL)
- timeline ordered by EXIF capture date, date-summary endpoint
- README rewritten with features, setup, full env var table
This commit is contained in:
2026-06-01 01:57:53 +02:00
parent 957737ac9b
commit 0077caa743
36 changed files with 752 additions and 125 deletions

View File

@@ -39,6 +39,11 @@ STORAGE_PATH=./data/media
# ============================================================================
# MAX_UPLOAD_BYTES=268435456
# ============================================================================
# Trash (default 30 days before permanent purge)
# ============================================================================
# TRASH_RETENTION_DAYS=30
# ============================================================================
# Logging
# ============================================================================

1
Cargo.lock generated
View File

@@ -4394,6 +4394,7 @@ dependencies = [
"application",
"async-nats",
"async-trait",
"chrono",
"domain",
"dotenvy",
"futures",

118
README.md
View File

@@ -10,6 +10,34 @@ Self-hosted media orchestrator and gallery. Alternative to Apple Photos, Google
- **Modular** — core works without AI/ML. Face detection, classification, smart search are optional plugins.
- **BYOS** — bring your own storage. Local NAS, S3, GCS — the domain doesn't care.
## Features
### Photo Management
- **Timeline** — date-grouped photo grid sorted by EXIF capture date, infinite scroll, date scrubber for fast navigation
- **Image viewer** — fullscreen with zoom/pan/pinch (react-zoom-pan-pinch), keyboard nav, collapsible metadata sidebar (EXIF, camera, location)
- **Albums** — create, add/remove photos, asset picker dialog
- **Upload** — drag-drop with per-file progress, sequential upload through Next.js proxy
- **Multi-select** — select photos to bulk add to albums or delete
- **Multi-volume** — import photos from NAS, external drives, or cloud storage without copying
### Safe Deletion
- **Read-only volumes** (NAS, archives): delete removes DB records + derivatives. Original files never touched.
- **Writable volumes** (uploads): soft-delete to trash with configurable grace period before permanent purge.
- **Trash** — view trashed photos, restore before purge. `TRASH_RETENTION_DAYS` (default 30).
### Admin
- **Storage** — register volumes + library paths, import library (one-click scan), delete
- **Jobs** — queue dashboard with filtering, pagination, error details, start/fail actions
- **Plugins** — list, enable/disable toggle, create
- **Pipelines** — list configured pipelines, create trigger-based processing chains
- **Sidecars** — detect changes, bulk export/import, per-asset conflict resolution
- **Duplicates** — view duplicate groups with thumbnails, resolve by picking keeper
### Auth
- JWT access tokens + refresh token rotation
- Role-based access: first registered user auto-promoted to admin
- Admin section in sidebar, hidden for regular users
## Architecture
Hexagonal / DDD with CQRS. Dependencies point inward:
@@ -38,59 +66,29 @@ Infrastructure (Axum, Postgres, NATS, S3)
```
crates/
domain/ pure Rust — entities, value objects, ports, services
common/ errors, events, value objects (SystemId, Checksum, Email,
Username, MimeType, RelativePath, etc.)
identity/ user, role, permission, group, refresh token
storage/ volumes, library paths, ingestion, quotas
catalog/ assets, metadata, stacks, derivatives, duplicates
organization/ albums, tags, collections
sharing/ share scopes, targets, links, invites
sidecar/ sidecar records, sync config
processing/ jobs, batches, plugins, pipelines
application/ CQRS commands + queries with Arc<dyn Port> injection
identity/commands/ RegisterUser, LoginUser, RefreshToken, Logout
identity/queries/ GetProfile
storage/commands/ RegisterVolume, RegisterLibraryPath, IngestAsset
storage/queries/ CheckQuota
catalog/commands/ RegisterAsset, UpdateMetadata, CreateStack, DeleteStack,
DeleteAsset, DetectLivePhotos, ResolveDuplicate
catalog/queries/ GetTimeline, GetAsset, GetStack, ReadAssetFile,
ReadDerivative, SearchAssets
organization/ CreateAlbum, ManageAlbumEntries, TagAsset, GetAlbum
sharing/ ShareResource, GenerateShareLink, RevokeShare, AccessSharedResource
sidecar/ ExportSidecar, DetectChanges, Import, ResolveConflict, FullExport/Import
processing/ EnqueueJob, StartJob, CompleteJob, FailJob, ExecutePipeline,
ManagePlugin, ConfigurePipeline, ListJobs, BatchProgress
testing/ in-memory repo fakes + stub ports (in_memory_repo! macro)
api-types/ HTTP request/response DTOs with OpenAPI derives
adapters/
auth/ bcrypt password hashing, JWT token issuer (configurable expiry)
postgres/ all repository implementations, event store, migrations
storage/ local filesystem + S3 (feature-gated)
auth/ bcrypt + JWT
postgres/ repos, event store, migrations
storage/ local filesystem, volume-aware file resolver
exif/ EXIF metadata extraction
thumbnail/ derivative generation
sidecar/ XMP sidecar reader/writer
event-payload/ domain event serialization
sidecar/ XMP reader/writer
event-transport/ composite publisher (NATS + event store)
nats/ NATS JetStream transport
presentation/ axum handlers, routes, extractors, middleware, parsers
bootstrap/ config, DI wiring, entry point
worker/ background job runner (NATS consumer)
presentation/ axum handlers, routes, middleware
bootstrap/ config, DI wiring, API server entry point
worker/ background job runner (NATS consumer, sweep, trash purge)
k-photos-frontend/ Next.js 16 + shadcn + TanStack Query
app/(auth)/ login, register
app/(app)/ timeline, albums, trash, admin pages
components/ photo grid, image viewer, upload dialog, sidebars
hooks/ auth, timeline, albums, upload, admin hooks
lib/ API client, token helpers, types
```
### Auth
- JWT access tokens (1h expiry, configurable)
- Refresh token rotation (30d, SHA-256 hashed, stored in Postgres)
- `POST /auth/login` — returns access + refresh tokens
- `POST /auth/refresh` — rotates refresh token, issues new pair
- `POST /auth/logout` — revokes all refresh tokens for user
- `require_auth` middleware on all protected routes (defense in depth)
- Handlers still use `JwtClaims` extractor for user_id — middleware is the safety net
- Admin-only endpoints gated by role check (processing, storage, sidecar management)
## Environment Variables
| Variable | Required | Default | Description |
@@ -102,31 +100,35 @@ crates/
| `HOST` | no | `0.0.0.0` | Bind address |
| `PORT` | no | `8000` | Bind port |
| `CORS_ALLOWED_ORIGINS` | no | — | Comma-separated origins |
| `MAX_UPLOAD_BYTES` | no | `268435456` | Max upload size (256 MiB) |
| `TRASH_RETENTION_DAYS` | no | `30` | Days before trashed assets are permanently purged |
| `RUST_LOG` | no | `info` | Log level filter |
## Development
```bash
# run tests (no DB required)
# prerequisites: postgres, nats-server, bun
# backend
cp .env.example .env # edit DATABASE_URL, JWT_SECRET
cargo run -p bootstrap # API server on :8000
cargo run -p worker # background job runner
# frontend
cd k-photos-frontend
bun install
bun run dev # Next.js on :3000 (proxies /api/v1 to :8000)
# tests
cargo test --workspace
# format + lint
cargo fmt --all
cargo clippy --workspace
```
206 tests cover domain entities, services, application use cases, and visibility filtering.
## Docker
```bash
docker build -t k-photos .
docker run -e DATABASE_URL=... -e JWT_SECRET=... -e NATS_URL=... -p 8000:8000 k-photos
```
Worker (background jobs):
```bash
docker run -e DATABASE_URL=... -e NATS_URL=... k-photos ./k_photos-worker
docker compose up -d # postgres + nats
cargo run -p bootstrap
cargo run -p worker
```
## License

View File

@@ -0,0 +1,3 @@
ALTER TABLE assets ADD COLUMN deleted_at TIMESTAMPTZ NULL;
ALTER TABLE assets ADD COLUMN deleted_by UUID NULL REFERENCES users(id);
CREATE INDEX idx_assets_deleted ON assets (deleted_at) WHERE deleted_at IS NOT NULL;

View File

@@ -33,6 +33,8 @@ struct AssetRow {
is_processed: bool,
owner_user_id: Uuid,
created_at: DateTime<Utc>,
deleted_at: Option<DateTime<Utc>>,
deleted_by: Option<Uuid>,
}
fn asset_type_from_str(s: &str) -> AssetType {
@@ -68,6 +70,8 @@ impl TryFrom<AssetRow> for Asset {
is_processed: r.is_processed,
owner_user_id: SystemId::from_uuid(r.owner_user_id),
created_at: DateTimeStamp::from_datetime(r.created_at),
deleted_at: r.deleted_at.map(DateTimeStamp::from_datetime),
deleted_by: r.deleted_by.map(SystemId::from_uuid),
})
}
}
@@ -157,7 +161,7 @@ impl AssetRepository for PostgresAssetRepository {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<Asset>, DomainError> {
let row = 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
file_size, is_processed, owner_user_id, created_at, deleted_at, deleted_by
FROM assets WHERE asset_id = $1",
)
.bind(*id.as_uuid())
@@ -171,7 +175,7 @@ impl AssetRepository for PostgresAssetRepository {
async fn find_by_checksum(&self, checksum: &Checksum) -> 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
file_size, is_processed, owner_user_id, created_at, deleted_at, deleted_by
FROM assets WHERE checksum = $1",
)
.bind(checksum.as_str())
@@ -190,11 +194,11 @@ impl AssetRepository for PostgresAssetRepository {
) -> Result<Vec<Asset>, DomainError> {
let rows = sqlx::query_as::<_, AssetRow>(
"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
a.file_size, a.is_processed, a.owner_user_id, a.created_at, a.deleted_at, a.deleted_by
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
WHERE a.owner_user_id = $1 AND a.deleted_at IS NULL
ORDER BY COALESCE(
(am.data->>'DateTimeOriginal')::timestamptz,
a.created_at
@@ -221,8 +225,8 @@ impl AssetRepository for PostgresAssetRepository {
let (where_clause, has_tag) = build_search_where(filters);
let mut sql = format!(
"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{} WHERE a.owner_user_id = $1{}",
a.file_size, a.is_processed, a.owner_user_id, a.created_at, a.deleted_at, a.deleted_by
FROM assets a{} WHERE a.owner_user_id = $1 AND a.deleted_at IS NULL{}",
if has_tag {
" JOIN asset_tags at ON at.asset_id = a.asset_id JOIN tags t ON t.tag_id = at.tag_id"
} else {
@@ -253,7 +257,7 @@ impl AssetRepository for PostgresAssetRepository {
async fn count_by_owner(&self, owner_id: &SystemId) -> Result<u64, DomainError> {
let (count,): (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM assets WHERE owner_user_id = $1")
sqlx::query_as("SELECT COUNT(*) FROM assets WHERE owner_user_id = $1 AND deleted_at IS NULL")
.bind(*owner_id.as_uuid())
.fetch_one(&self.pool)
.await
@@ -268,7 +272,7 @@ impl AssetRepository for PostgresAssetRepository {
) -> Result<u64, DomainError> {
let (where_clause, has_tag) = build_search_where(filters);
let sql = format!(
"SELECT COUNT(*) FROM assets a{} WHERE a.owner_user_id = $1{}",
"SELECT COUNT(*) FROM assets a{} WHERE a.owner_user_id = $1 AND a.deleted_at IS NULL{}",
if has_tag {
" JOIN asset_tags at ON at.asset_id = a.asset_id JOIN tags t ON t.tag_id = at.tag_id"
} else {
@@ -315,7 +319,7 @@ impl AssetRepository for PostgresAssetRepository {
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
WHERE a.owner_user_id = $1 AND a.deleted_at IS NULL
GROUP BY day ORDER BY day DESC",
)
.bind(*owner_id.as_uuid())
@@ -365,6 +369,84 @@ impl AssetRepository for PostgresAssetRepository {
.map_pg()?;
Ok(())
}
async fn soft_delete(
&self,
id: &SystemId,
deleted_by: &SystemId,
) -> Result<(), DomainError> {
sqlx::query(
"UPDATE assets SET deleted_at = NOW(), deleted_by = $2 WHERE asset_id = $1",
)
.bind(*id.as_uuid())
.bind(*deleted_by.as_uuid())
.execute(&self.pool)
.await
.map_pg()?;
Ok(())
}
async fn restore(&self, id: &SystemId) -> Result<(), DomainError> {
sqlx::query(
"UPDATE assets SET deleted_at = NULL, deleted_by = NULL WHERE asset_id = $1",
)
.bind(*id.as_uuid())
.execute(&self.pool)
.await
.map_pg()?;
Ok(())
}
async fn find_trashed_before(
&self,
cutoff: chrono::DateTime<chrono::Utc>,
) -> 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, deleted_at, deleted_by
FROM assets WHERE deleted_at IS NOT NULL AND deleted_at < $1",
)
.bind(cutoff)
.fetch_all(&self.pool)
.await
.map_pg()?;
rows.into_iter().map(TryInto::try_into).collect()
}
async fn count_trashed(&self, owner_id: &SystemId) -> Result<u64, DomainError> {
let (count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM assets WHERE owner_user_id = $1 AND deleted_at IS NOT NULL",
)
.bind(*owner_id.as_uuid())
.fetch_one(&self.pool)
.await
.map_pg()?;
Ok(count as u64)
}
async fn find_trashed_by_owner(
&self,
owner_id: &SystemId,
limit: u32,
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, deleted_at, deleted_by
FROM assets WHERE owner_user_id = $1 AND deleted_at IS NOT NULL
ORDER BY deleted_at DESC
LIMIT $2 OFFSET $3",
)
.bind(*owner_id.as_uuid())
.bind(limit as i64)
.bind(offset as i64)
.fetch_all(&self.pool)
.await
.map_pg()?;
rows.into_iter().map(TryInto::try_into).collect()
}
}
// ──────────────────────────────────────────────

View File

@@ -5,6 +5,7 @@ use domain::{
events::DomainEvent,
ports::{
AssetRepository, DerivativeRepository, EventPublisher, FileStoragePort, SidecarRepository,
StorageVolumeRepository,
},
value_objects::{DateTimeStamp, SystemId},
};
@@ -16,6 +17,7 @@ pub struct DeleteAssetCommand {
pub struct DeleteAssetHandler {
asset_repo: Arc<dyn AssetRepository>,
volume_repo: Arc<dyn StorageVolumeRepository>,
derivative_repo: Arc<dyn DerivativeRepository>,
sidecar_repo: Arc<dyn SidecarRepository>,
file_storage: Arc<dyn FileStoragePort>,
@@ -25,6 +27,7 @@ pub struct DeleteAssetHandler {
impl DeleteAssetHandler {
pub fn new(
asset_repo: Arc<dyn AssetRepository>,
volume_repo: Arc<dyn StorageVolumeRepository>,
derivative_repo: Arc<dyn DerivativeRepository>,
sidecar_repo: Arc<dyn SidecarRepository>,
file_storage: Arc<dyn FileStoragePort>,
@@ -32,6 +35,7 @@ impl DeleteAssetHandler {
) -> Self {
Self {
asset_repo,
volume_repo,
derivative_repo,
sidecar_repo,
file_storage,
@@ -46,31 +50,24 @@ impl DeleteAssetHandler {
.await?
.ok_or_else(|| DomainError::NotFound("Asset not found".into()))?;
// Delete derivative files + DB records
let derivatives = self.derivative_repo.find_by_asset(&cmd.asset_id).await?;
for d in &derivatives {
let _ = self.file_storage.delete_file(&d.storage_path).await;
self.derivative_repo.delete(&d.derivative_id).await?;
let volume = self
.volume_repo
.find_by_id(&asset.source_reference.volume_id)
.await?
.ok_or_else(|| DomainError::NotFound("Volume not found".into()))?;
if volume.is_writable {
// Writable volume: soft-delete, keep files for grace period
self.asset_repo
.soft_delete(&cmd.asset_id, &cmd.deleted_by)
.await?;
} else {
// Read-only volume: remove DB records + derivatives, never touch original
self.cleanup_derivatives(&cmd.asset_id).await?;
self.cleanup_sidecar(&cmd.asset_id).await?;
self.asset_repo.delete(&cmd.asset_id).await?;
}
// Delete sidecar file + DB record
if let Some(sidecar) = self.sidecar_repo.find_by_asset(&cmd.asset_id).await? {
let _ = self
.file_storage
.delete_file(&sidecar.sidecar_storage_path)
.await;
self.sidecar_repo.delete(&cmd.asset_id).await?;
}
// Delete asset file
let _ = self
.file_storage
.delete_file(&asset.source_reference.relative_path)
.await;
// Delete asset DB record
self.asset_repo.delete(&cmd.asset_id).await?;
self.event_publisher
.publish(&DomainEvent::AssetDeleted {
asset_id: cmd.asset_id,
@@ -81,4 +78,51 @@ impl DeleteAssetHandler {
Ok(())
}
pub async fn purge(&self, asset_id: &SystemId) -> Result<(), DomainError> {
let asset = self
.asset_repo
.find_by_id(asset_id)
.await?
.ok_or_else(|| DomainError::NotFound("Asset not found".into()))?;
self.cleanup_derivatives(asset_id).await?;
self.cleanup_sidecar(asset_id).await?;
let volume = self
.volume_repo
.find_by_id(&asset.source_reference.volume_id)
.await?;
if let Some(v) = volume {
if v.is_writable {
let _ = self
.file_storage
.delete_file(&asset.source_reference.relative_path)
.await;
}
}
self.asset_repo.delete(asset_id).await?;
Ok(())
}
async fn cleanup_derivatives(&self, asset_id: &SystemId) -> Result<(), DomainError> {
let derivatives = self.derivative_repo.find_by_asset(asset_id).await?;
for d in &derivatives {
let _ = self.file_storage.delete_file(&d.storage_path).await;
self.derivative_repo.delete(&d.derivative_id).await?;
}
Ok(())
}
async fn cleanup_sidecar(&self, asset_id: &SystemId) -> Result<(), DomainError> {
if let Some(sidecar) = self.sidecar_repo.find_by_asset(asset_id).await? {
let _ = self
.file_storage
.delete_file(&sidecar.sidecar_storage_path)
.await;
self.sidecar_repo.delete(asset_id).await?;
}
Ok(())
}
}

View File

@@ -1,5 +1,6 @@
pub mod create_stack;
pub mod delete_asset;
pub mod restore_asset;
pub mod detect_live_photos;
pub mod register_asset;
pub mod resolve_duplicate;

View File

@@ -0,0 +1,35 @@
use domain::{errors::DomainError, ports::AssetRepository, value_objects::SystemId};
use std::sync::Arc;
pub struct RestoreAssetCommand {
pub asset_id: SystemId,
pub user_id: SystemId,
}
pub struct RestoreAssetHandler {
asset_repo: Arc<dyn AssetRepository>,
}
impl RestoreAssetHandler {
pub fn new(asset_repo: Arc<dyn AssetRepository>) -> Self {
Self { asset_repo }
}
pub async fn execute(&self, cmd: RestoreAssetCommand) -> Result<(), DomainError> {
let asset = self
.asset_repo
.find_by_id(&cmd.asset_id)
.await?
.ok_or_else(|| DomainError::NotFound("Asset not found".into()))?;
if asset.owner_user_id != cmd.user_id {
return Err(DomainError::Forbidden("Access denied".into()));
}
if !asset.is_deleted() {
return Err(DomainError::Validation("Asset is not trashed".into()));
}
self.asset_repo.restore(&cmd.asset_id).await
}
}

View File

@@ -6,6 +6,7 @@ pub use commands::create_stack::{
CreateStackCommand, CreateStackHandler, DeleteStackCommand, DeleteStackHandler,
};
pub use commands::delete_asset::{DeleteAssetCommand, DeleteAssetHandler};
pub use commands::restore_asset::{RestoreAssetCommand, RestoreAssetHandler};
pub use commands::detect_live_photos::{DetectLivePhotosCommand, DetectLivePhotosHandler};
pub use commands::register_asset::{RegisterAssetCommand, RegisterAssetHandler};
pub use commands::resolve_duplicate::{
@@ -17,6 +18,7 @@ pub use queries::get_date_summary::{DateSummaryEntry, GetDateSummaryHandler, Get
pub use queries::get_stack::{GetStackHandler, GetStackQuery};
pub use queries::get_timeline::{GetTimelineHandler, GetTimelineQuery, TimelineResult};
pub use queries::list_stacks::{ListStacksHandler, ListStacksQuery};
pub use queries::list_trash::{ListTrashHandler, ListTrashQuery, TrashResult};
pub use queries::read_asset_file::{AssetFileResult, ReadAssetFileHandler, ReadAssetFileQuery};
pub use queries::read_derivative::{
DerivativeFileResult, ReadDerivativeHandler, ReadDerivativeQuery,

View File

@@ -0,0 +1,34 @@
use domain::{
entities::Asset, errors::DomainError, ports::AssetRepository, value_objects::SystemId,
};
use std::sync::Arc;
pub struct ListTrashQuery {
pub owner_id: SystemId,
pub limit: u32,
pub offset: u32,
}
pub struct TrashResult {
pub assets: Vec<Asset>,
pub total: u64,
}
pub struct ListTrashHandler {
asset_repo: Arc<dyn AssetRepository>,
}
impl ListTrashHandler {
pub fn new(asset_repo: Arc<dyn AssetRepository>) -> Self {
Self { asset_repo }
}
pub async fn execute(&self, query: ListTrashQuery) -> Result<TrashResult, DomainError> {
let total = self.asset_repo.count_trashed(&query.owner_id).await?;
let assets = self
.asset_repo
.find_trashed_by_owner(&query.owner_id, query.limit, query.offset)
.await?;
Ok(TrashResult { assets, total })
}
}

View File

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

View File

@@ -148,6 +148,26 @@ impl AssetRepository for VisibilityFilteredAssetRepository {
async fn delete(&self, id: &SystemId) -> Result<(), DomainError> {
self.inner.delete(id).await
}
async fn soft_delete(&self, id: &SystemId, deleted_by: &SystemId) -> Result<(), DomainError> {
self.inner.soft_delete(id, deleted_by).await
}
async fn restore(&self, id: &SystemId) -> Result<(), DomainError> {
self.inner.restore(id).await
}
async fn find_trashed_before(&self, cutoff: chrono::DateTime<chrono::Utc>) -> Result<Vec<Asset>, DomainError> {
self.inner.find_trashed_before(cutoff).await
}
async fn count_trashed(&self, owner_id: &SystemId) -> Result<u64, DomainError> {
self.inner.count_trashed(owner_id).await
}
async fn find_trashed_by_owner(&self, owner_id: &SystemId, limit: u32, offset: u32) -> Result<Vec<Asset>, DomainError> {
self.inner.find_trashed_by_owner(owner_id, limit, offset).await
}
}
#[cfg(test)]

View File

@@ -0,0 +1,31 @@
use domain::{errors::DomainError, ports::AlbumRepository, value_objects::SystemId};
use std::sync::Arc;
pub struct DeleteAlbumCommand {
pub album_id: SystemId,
pub user_id: SystemId,
}
pub struct DeleteAlbumHandler {
repo: Arc<dyn AlbumRepository>,
}
impl DeleteAlbumHandler {
pub fn new(repo: Arc<dyn AlbumRepository>) -> Self {
Self { repo }
}
pub async fn execute(&self, cmd: DeleteAlbumCommand) -> Result<(), DomainError> {
let album = self
.repo
.find_by_id(&cmd.album_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("Album {} not found", cmd.album_id)))?;
if album.creator_user_id != cmd.user_id {
return Err(DomainError::Forbidden("Access denied".into()));
}
self.repo.delete(&cmd.album_id).await
}
}

View File

@@ -1,9 +1,11 @@
pub mod create_album;
pub mod delete_album;
pub mod manage_album_entries;
pub mod tag_asset;
pub mod update_album;
pub use create_album::{CreateAlbumCommand, CreateAlbumHandler};
pub use delete_album::{DeleteAlbumCommand, DeleteAlbumHandler};
pub use manage_album_entries::{AlbumAction, ManageAlbumEntriesCommand, ManageAlbumEntriesHandler};
pub use tag_asset::{TagAssetCommand, TagAssetHandler};
pub use update_album::{UpdateAlbumCommand, UpdateAlbumHandler};

View File

@@ -3,6 +3,7 @@ pub mod queries;
pub use commands::{AlbumAction, ManageAlbumEntriesCommand, ManageAlbumEntriesHandler};
pub use commands::{CreateAlbumCommand, CreateAlbumHandler};
pub use commands::{DeleteAlbumCommand, DeleteAlbumHandler};
pub use commands::{TagAssetCommand, TagAssetHandler};
pub use commands::{UpdateAlbumCommand, UpdateAlbumHandler};
pub use queries::get_album::{GetAlbumHandler, GetAlbumQuery};

View File

@@ -204,6 +204,39 @@ impl AssetRepository for InMemoryAssetRepository {
self.data.lock().await.remove(&id.to_string());
Ok(())
}
async fn soft_delete(&self, id: &SystemId, deleted_by: &SystemId) -> Result<(), DomainError> {
if let Some(asset) = self.data.lock().await.get_mut(&id.to_string()) {
asset.trash(*deleted_by);
}
Ok(())
}
async fn restore(&self, id: &SystemId) -> Result<(), DomainError> {
if let Some(asset) = self.data.lock().await.get_mut(&id.to_string()) {
asset.restore();
}
Ok(())
}
async fn find_trashed_before(&self, cutoff: chrono::DateTime<chrono::Utc>) -> Result<Vec<Asset>, DomainError> {
Ok(self.data.lock().await.values()
.filter(|a| a.deleted_at.as_ref().map_or(false, |d| *d.as_datetime() < cutoff))
.cloned().collect())
}
async fn count_trashed(&self, owner_id: &SystemId) -> Result<u64, DomainError> {
Ok(self.data.lock().await.values()
.filter(|a| &a.owner_user_id == owner_id && a.is_deleted())
.count() as u64)
}
async fn find_trashed_by_owner(&self, owner_id: &SystemId, limit: u32, offset: u32) -> Result<Vec<Asset>, DomainError> {
Ok(self.data.lock().await.values()
.filter(|a| &a.owner_user_id == owner_id && a.is_deleted())
.skip(offset as usize).take(limit as usize)
.cloned().collect())
}
}
in_memory_repo!(InMemoryAlbumRepository, Album);

View File

@@ -10,9 +10,9 @@ use domain::ports::FileStoragePort;
use application::catalog::{
CreateStackHandler, DeleteAssetHandler, DeleteStackHandler, DetectLivePhotosHandler,
GetAssetHandler, GetDateSummaryHandler, GetStackHandler, GetTimelineHandler,
ListDuplicatesHandler,
ListDuplicatesHandler, ListTrashHandler,
ReadAssetFileHandler, ReadDerivativeHandler, RegisterAssetHandler, ResolveDuplicateHandler,
SearchAssetsHandler, UpdateMetadataHandler,
RestoreAssetHandler, SearchAssetsHandler, UpdateMetadataHandler,
};
use application::storage::IngestAssetHandler;
use domain::ports::EventPublisher;
@@ -77,12 +77,16 @@ pub fn build(
let delete_asset = Arc::new(DeleteAssetHandler::new(
asset_repo.clone(),
storage_repos.volume_repo.clone(),
derivative_repo.clone(),
sidecar_repo,
file_storage.clone(),
event_publisher.clone(),
));
let restore_asset = Arc::new(RestoreAssetHandler::new(asset_repo.clone()));
let list_trash = Arc::new(ListTrashHandler::new(asset_repo.clone()));
let list_duplicates = Arc::new(ListDuplicatesHandler::new(duplicate_repo.clone()));
let resolve_duplicate = Arc::new(ResolveDuplicateHandler::new(
duplicate_repo.clone(),
@@ -116,6 +120,8 @@ pub fn build(
read_derivative,
register_asset,
delete_asset,
restore_asset,
list_trash,
search_assets,
list_duplicates,
resolve_duplicate,

View File

@@ -4,8 +4,8 @@ use adapters_postgres::{
PgPool, PostgresAlbumRepository, PostgresAssetRepository, PostgresTagRepository,
};
use application::organization::{
CreateAlbumHandler, GetAlbumHandler, ListAlbumsHandler, ManageAlbumEntriesHandler,
TagAssetHandler, UpdateAlbumHandler,
CreateAlbumHandler, DeleteAlbumHandler, GetAlbumHandler, ListAlbumsHandler,
ManageAlbumEntriesHandler, TagAssetHandler, UpdateAlbumHandler,
};
use presentation::state::OrganizationHandlers;
@@ -18,11 +18,13 @@ pub fn build(pool: &PgPool) -> OrganizationHandlers {
let get_album = Arc::new(GetAlbumHandler::new(album_repo.clone()));
let list_albums = Arc::new(ListAlbumsHandler::new(album_repo.clone()));
let update_album = Arc::new(UpdateAlbumHandler::new(album_repo.clone()));
let delete_album = Arc::new(DeleteAlbumHandler::new(album_repo.clone()));
let manage_album_entries = Arc::new(ManageAlbumEntriesHandler::new(album_repo));
let tag_asset = Arc::new(TagAssetHandler::new(asset_repo, tag_repo));
OrganizationHandlers {
create_album,
delete_album,
get_album,
list_albums,
manage_album_entries,

View File

@@ -27,6 +27,8 @@ pub struct Asset {
pub is_processed: bool,
pub owner_user_id: SystemId,
pub created_at: DateTimeStamp,
pub deleted_at: Option<DateTimeStamp>,
pub deleted_by: Option<SystemId>,
}
impl Asset {
@@ -46,12 +48,28 @@ impl Asset {
is_processed: false,
owner_user_id: owner,
created_at: DateTimeStamp::now(),
deleted_at: None,
deleted_by: None,
}
}
pub fn mark_processed(&mut self) {
self.is_processed = true;
}
pub fn is_deleted(&self) -> bool {
self.deleted_at.is_some()
}
pub fn trash(&mut self, by: SystemId) {
self.deleted_at = Some(DateTimeStamp::now());
self.deleted_by = Some(by);
}
pub fn restore(&mut self) {
self.deleted_at = None;
self.deleted_by = None;
}
}
// --- AssetFilters ---

View File

@@ -38,6 +38,23 @@ pub trait AssetRepository: Send + Sync {
) -> Result<Vec<(chrono::NaiveDate, u64)>, DomainError>;
async fn save(&self, asset: &Asset) -> Result<(), DomainError>;
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
async fn soft_delete(
&self,
id: &SystemId,
deleted_by: &SystemId,
) -> Result<(), DomainError>;
async fn restore(&self, id: &SystemId) -> Result<(), DomainError>;
async fn find_trashed_before(
&self,
cutoff: chrono::DateTime<chrono::Utc>,
) -> Result<Vec<Asset>, DomainError>;
async fn count_trashed(&self, owner_id: &SystemId) -> Result<u64, DomainError>;
async fn find_trashed_by_owner(
&self,
owner_id: &SystemId,
limit: u32,
offset: u32,
) -> Result<Vec<Asset>, DomainError>;
}
// --- AssetMetadataRepository ---

View File

@@ -5,8 +5,8 @@ use api_types::{
responses::AlbumResponse,
};
use application::organization::{
AlbumAction, CreateAlbumCommand, GetAlbumQuery, ListAlbumsQuery, ManageAlbumEntriesCommand,
UpdateAlbumCommand,
AlbumAction, CreateAlbumCommand, DeleteAlbumCommand, GetAlbumQuery, ListAlbumsQuery,
ManageAlbumEntriesCommand, UpdateAlbumCommand,
};
use axum::{
Json,
@@ -108,6 +108,19 @@ pub async fn update_album(
Ok(Json(AlbumResponse::from_domain(&album)))
}
pub async fn delete_album(
State(state): State<AppState>,
claims: JwtClaims,
Path((album_id,)): Path<(uuid::Uuid,)>,
) -> Result<StatusCode, AppError> {
let cmd = DeleteAlbumCommand {
album_id: SystemId::from_uuid(album_id),
user_id: claims.user_id,
};
state.organization.delete_album.execute(cmd).await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
post, path = "/api/v1/albums/{id}/entries",
request_body = AlbumEntryRequest,

View File

@@ -14,8 +14,9 @@ use api_types::{
};
use application::{
catalog::{
DeleteAssetCommand, GetAssetQuery, GetDateSummaryQuery, GetTimelineQuery, ReadAssetFileQuery,
ReadDerivativeQuery, RegisterAssetCommand, SearchAssetsQuery, UpdateMetadataCommand,
DeleteAssetCommand, GetAssetQuery, GetDateSummaryQuery, GetTimelineQuery, ListTrashQuery,
ReadAssetFileQuery, ReadDerivativeQuery, RegisterAssetCommand, RestoreAssetCommand,
SearchAssetsQuery, UpdateMetadataCommand,
},
organization::TagAssetCommand,
storage::IngestAssetCommand,
@@ -473,3 +474,46 @@ pub async fn bulk_tag(
}
Ok(Json(serde_json::json!({ "tagged": tagged })))
}
pub async fn restore_asset(
State(state): State<AppState>,
claims: JwtClaims,
Path((asset_id,)): Path<(uuid::Uuid,)>,
) -> Result<StatusCode, AppError> {
let cmd = RestoreAssetCommand {
asset_id: SystemId::from_uuid(asset_id),
user_id: claims.user_id,
};
state.catalog.restore_asset.execute(cmd).await?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Debug, serde::Deserialize)]
pub struct TrashParams {
pub limit: Option<u32>,
pub offset: Option<u32>,
}
pub async fn list_trash(
State(state): State<AppState>,
claims: JwtClaims,
Query(params): Query<TrashParams>,
) -> Result<Json<TimelineResponse>, AppError> {
let limit = params.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(MAX_PAGE_SIZE);
let offset = params.offset.unwrap_or(0);
let query = ListTrashQuery {
owner_id: claims.user_id,
limit,
offset,
};
let result = state.catalog.list_trash.execute(query).await?;
let items = result
.assets
.iter()
.map(|a| AssetResponse::from_domain(a, &StructuredData::new()))
.collect();
Ok(Json(TimelineResponse {
assets: items,
total: result.total,
}))
}

View File

@@ -25,6 +25,8 @@ pub fn routes() -> Router<AppState> {
get(assets::serve_derivative),
)
.route("/assets/{id}/tags", post(assets::tag_asset))
.route("/assets/trash", get(assets::list_trash))
.route("/assets/{id}/restore", post(assets::restore_asset))
.route("/assets/bulk-delete", post(assets::bulk_delete))
.route("/assets/bulk-tag", post(assets::bulk_tag))
.route(

View File

@@ -12,7 +12,9 @@ pub fn routes() -> Router<AppState> {
)
.route(
"/albums/{id}",
get(albums::get_album).put(albums::update_album),
get(albums::get_album)
.put(albums::update_album)
.delete(albums::delete_album),
)
.route("/albums/{id}/entries", post(albums::add_entry))
.route(

View File

@@ -4,17 +4,17 @@ use application::{
catalog::{
CreateStackHandler, DeleteAssetHandler, DeleteStackHandler, DetectLivePhotosHandler,
GetAssetHandler, GetDateSummaryHandler, GetStackHandler, GetTimelineHandler,
ListDuplicatesHandler, ListStacksHandler, ReadAssetFileHandler, ReadDerivativeHandler,
RegisterAssetHandler, ResolveDuplicateHandler, SearchAssetsHandler,
UpdateMetadataHandler,
ListDuplicatesHandler, ListStacksHandler, ListTrashHandler, ReadAssetFileHandler,
ReadDerivativeHandler, RegisterAssetHandler, ResolveDuplicateHandler,
RestoreAssetHandler, SearchAssetsHandler, UpdateMetadataHandler,
},
identity::{
GetProfileHandler, LoginUserHandler, LogoutHandler, RefreshTokenHandler,
RegisterUserHandler,
},
organization::{
CreateAlbumHandler, GetAlbumHandler, ListAlbumsHandler, ManageAlbumEntriesHandler,
TagAssetHandler, UpdateAlbumHandler,
CreateAlbumHandler, DeleteAlbumHandler, GetAlbumHandler, ListAlbumsHandler,
ManageAlbumEntriesHandler, TagAssetHandler, UpdateAlbumHandler,
},
processing::{
CompleteJobHandler, ConfigurePipelineHandler, EnqueueJobHandler, FailJobHandler,
@@ -58,6 +58,8 @@ pub struct CatalogHandlers {
pub read_derivative: Arc<ReadDerivativeHandler>,
pub register_asset: Arc<RegisterAssetHandler>,
pub delete_asset: Arc<DeleteAssetHandler>,
pub restore_asset: Arc<RestoreAssetHandler>,
pub list_trash: Arc<ListTrashHandler>,
pub search_assets: Arc<SearchAssetsHandler>,
pub list_duplicates: Arc<ListDuplicatesHandler>,
pub resolve_duplicate: Arc<ResolveDuplicateHandler>,
@@ -71,6 +73,7 @@ pub struct CatalogHandlers {
#[derive(Clone)]
pub struct OrganizationHandlers {
pub create_album: Arc<CreateAlbumHandler>,
pub delete_album: Arc<DeleteAlbumHandler>,
pub get_album: Arc<GetAlbumHandler>,
pub list_albums: Arc<ListAlbumsHandler>,
pub manage_album_entries: Arc<ManageAlbumEntriesHandler>,

View File

@@ -29,3 +29,4 @@ tracing = { workspace = true }
tracing-subscriber = { workspace = true }
dotenvy = { workspace = true }
async-trait = { workspace = true }
chrono = { workspace = true }

View File

@@ -1,7 +1,8 @@
use std::sync::Arc;
use application::catalog::DeleteAssetHandler;
use application::processing::{EnqueueJobHandler, ProcessNextJobHandler};
use domain::ports::JobRepository;
use domain::ports::{AssetRepository, JobRepository};
use crate::config::WorkerConfig;
use crate::factories::{
@@ -12,6 +13,9 @@ pub struct WorkerServices {
pub process_next: Arc<ProcessNextJobHandler>,
pub enqueue: Arc<EnqueueJobHandler>,
pub job_repo: Arc<dyn JobRepository>,
pub asset_repo: Arc<dyn AssetRepository>,
pub delete_handler: Arc<DeleteAssetHandler>,
pub trash_retention_days: u64,
pub event_consumer:
adapters_event_transport::EventConsumerAdapter<adapters_nats::NatsMessageSource>,
}
@@ -27,9 +31,8 @@ pub async fn build(config: &WorkerConfig) -> anyhow::Result<WorkerServices> {
let event_store: Arc<dyn domain::ports::EventStore> =
Arc::new(adapters_postgres::PostgresEventStore::new(pool.clone()));
let repos = Repos::new(pool);
let file_storage = Arc::new(adapters_storage::LocalFileStorage::new(
&config.storage_path,
));
let file_storage: Arc<dyn domain::ports::FileStoragePort> =
Arc::new(adapters_storage::LocalFileStorage::new(&config.storage_path));
let sidecar_writer: Arc<dyn domain::ports::SidecarWriterPort> =
Arc::new(adapters_sidecar::XmpSidecarWriter);
@@ -47,7 +50,7 @@ pub async fn build(config: &WorkerConfig) -> anyhow::Result<WorkerServices> {
Arc::new(adapters_thumbnail::ImageThumbnailGenerator);
let registry = Arc::new(build_plugin_registry(
&repos,
file_storage,
file_storage.clone(),
sidecar_writer,
extractor,
thumbnail_gen,
@@ -60,7 +63,18 @@ pub async fn build(config: &WorkerConfig) -> anyhow::Result<WorkerServices> {
event_pub.clone(),
));
let job_repo: Arc<dyn JobRepository> = repos.job.clone();
let enqueue = Arc::new(build_enqueue_handler(&repos, event_pub));
let asset_repo: Arc<dyn AssetRepository> = repos.asset.clone();
let enqueue = Arc::new(build_enqueue_handler(&repos, event_pub.clone()));
let sidecar_repo: Arc<dyn domain::ports::SidecarRepository> = repos.sidecar.clone();
let delete_handler = Arc::new(DeleteAssetHandler::new(
repos.asset.clone(),
repos.volume.clone(),
repos.derivative.clone(),
sidecar_repo,
file_storage,
event_pub,
));
let consumer_source = adapters_nats::NatsMessageSource::new(nats_client);
let event_consumer = adapters_event_transport::EventConsumerAdapter::new(consumer_source);
@@ -69,6 +83,9 @@ pub async fn build(config: &WorkerConfig) -> anyhow::Result<WorkerServices> {
process_next,
enqueue,
job_repo,
asset_repo,
delete_handler,
trash_retention_days: config.trash_retention_days,
event_consumer,
})
}

View File

@@ -4,6 +4,7 @@ pub struct WorkerConfig {
pub nats_url: String,
pub fallback_sweep_secs: u64,
pub storage_path: String,
pub trash_retention_days: u64,
}
impl WorkerConfig {
@@ -17,6 +18,10 @@ impl WorkerConfig {
.and_then(|v| v.parse().ok())
.unwrap_or(60),
storage_path: std::env::var("STORAGE_PATH").unwrap_or_else(|_| "./storage".into()),
trash_retention_days: std::env::var("TRASH_RETENTION_DAYS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(30),
}
}
}

View File

@@ -54,6 +54,13 @@ async fn main() -> anyhow::Result<()> {
shutdown_rx.clone(),
));
tokio::spawn(sweep::purge_trash(
services.asset_repo.clone(),
services.delete_handler.clone(),
services.trash_retention_days,
shutdown_rx.clone(),
));
event_loop::run(services, shutdown_rx).await;
info!("worker shutdown complete");

View File

@@ -4,7 +4,9 @@ use std::time::Duration;
use tokio::sync::watch;
use tracing::{error, info};
use application::catalog::DeleteAssetHandler;
use application::processing::{ProcessNextJobCommand, ProcessNextJobHandler};
use domain::ports::AssetRepository;
pub async fn run(
handler: Arc<ProcessNextJobHandler>,
@@ -35,3 +37,37 @@ pub async fn run(
}
}
}
pub async fn purge_trash(
asset_repo: Arc<dyn AssetRepository>,
delete_handler: Arc<DeleteAssetHandler>,
retention_days: u64,
mut shutdown: watch::Receiver<bool>,
) {
let interval = Duration::from_secs(3600);
info!(retention_days, "trash purge task started");
loop {
tokio::select! {
_ = shutdown.changed() => {
info!("trash purge: shutting down");
break;
}
_ = tokio::time::sleep(interval) => {}
}
let cutoff = chrono::Utc::now() - chrono::Duration::days(retention_days as i64);
match asset_repo.find_trashed_before(cutoff).await {
Ok(assets) if assets.is_empty() => {}
Ok(assets) => {
info!(count = assets.len(), "trash purge: purging expired assets");
for asset in &assets {
if let Err(e) = delete_handler.purge(&asset.asset_id).await {
error!(asset_id = %asset.asset_id, error = %e, "trash purge: failed");
}
}
}
Err(e) => {
error!(error = %e, "trash purge: failed to query trashed assets");
}
}
}
}

View File

@@ -16,7 +16,7 @@ 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 { CameraIcon, LogOutIcon, Trash2Icon } from "lucide-react"
import { Button } from "@/components/ui/button"
import Link from "next/link"
@@ -53,13 +53,22 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
<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 className="flex flex-col border-t">
<Link
href="/trash"
className="flex items-center gap-2 px-4 py-2 text-xs text-muted-foreground hover:text-foreground"
>
<Trash2Icon className="h-3.5 w-3.5" />
Trash
</Link>
<div className="flex items-center justify-between 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>
</div>
</Sidebar>
<SidebarInset>

View File

@@ -1,16 +1,35 @@
"use client"
import { useMemo } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { useTimeline, useDateSummary } from "@/hooks/use-timeline"
import { groupByDate } from "@/lib/timeline"
import { PhotoGrid } from "@/components/photo-grid"
import { DateScrubber } from "@/components/date-scrubber"
import api from "@/lib/api"
import { toast } from "sonner"
export default function TimelinePage() {
const qc = useQueryClient()
const { assets, isLoading, hasMore, loadMore, total } = useTimeline()
const { data: dateSummary } = useDateSummary()
const groups = useMemo(() => groupByDate(assets), [assets])
const handleDeleteAssets = async (ids: string[]) => {
let deleted = 0
for (const id of ids) {
try {
await api.delete(`/assets/${id}`)
deleted++
} catch { /* skip */ }
}
if (deleted > 0) {
toast.success(`Deleted ${deleted} photo(s)`)
qc.invalidateQueries({ queryKey: ["timeline"] })
qc.invalidateQueries({ queryKey: ["date-summary"] })
}
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
@@ -27,6 +46,7 @@ export default function TimelinePage() {
isLoading={isLoading}
hasMore={hasMore}
onLoadMore={() => loadMore()}
onDeleteAssets={handleDeleteAssets}
/>
<DateScrubber dates={dateSummary ?? []} />
</div>

View File

@@ -0,0 +1,87 @@
"use client"
import { useEffect, useState } from "react"
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
import api from "@/lib/api"
import type { TimelineResponse, AssetResponse } from "@/lib/types"
import { PhotoCard } from "@/components/photo-card"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { toast } from "sonner"
import { RotateCcwIcon } from "lucide-react"
export default function TrashPage() {
const qc = useQueryClient()
const { data, isLoading } = useQuery({
queryKey: ["trash"],
queryFn: async () => {
const { data } = await api.get<TimelineResponse>("/assets/trash", {
params: { limit: 100, offset: 0 },
})
return data
},
})
const restore = useMutation({
mutationFn: async (assetId: string) => {
await api.post(`/assets/${assetId}/restore`)
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["trash"] })
qc.invalidateQueries({ queryKey: ["timeline"] })
qc.invalidateQueries({ queryKey: ["date-summary"] })
},
})
const assets = data?.assets ?? []
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h1 className="text-lg font-semibold">Trash</h1>
{data && data.total > 0 && (
<span className="text-sm text-muted-foreground">
{data.total} items
</span>
)}
</div>
{isLoading ? (
<Spinner />
) : assets.length === 0 ? (
<p className="py-12 text-center text-muted-foreground">
Trash is empty
</p>
) : (
<div className="grid grid-cols-2 gap-1 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{assets.map((asset) => (
<div key={asset.id} className="group relative">
<div className="opacity-60">
<PhotoCard asset={asset} />
</div>
<div className="absolute top-1.5 right-1.5 opacity-0 group-hover:opacity-100">
<Button
size="icon"
variant="secondary"
className="h-7 w-7"
disabled={restore.isPending}
onClick={async () => {
try {
await restore.mutateAsync(asset.id)
toast.success("Photo restored")
} catch {
toast.error("Failed to restore")
}
}}
>
<RotateCcwIcon className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -14,11 +14,12 @@ import {
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { useAlbums } from "@/hooks/use-albums"
import { ImageIcon, PlusIcon } from "lucide-react"
import { toast } from "sonner"
import { ImageIcon, PlusIcon, Trash2Icon } from "lucide-react"
export function AlbumSidebar() {
const pathname = usePathname()
const { albums, createAlbum } = useAlbums()
const { albums, createAlbum, deleteAlbum } = useAlbums()
const [isCreating, setIsCreating] = useState(false)
const [newTitle, setNewTitle] = useState("")
@@ -59,14 +60,31 @@ export function AlbumSidebar() {
)}
<SidebarMenu>
{albums.map((album) => (
<SidebarMenuItem key={album.id}>
<SidebarMenuItem key={album.id} className="group/album">
<SidebarMenuButton
asChild
isActive={pathname === `/albums/${album.id}`}
>
<Link href={`/albums/${album.id}`}>
<ImageIcon className="h-4 w-4" />
<span>{album.title}</span>
<span className="flex-1">{album.title}</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 opacity-0 group-hover/album:opacity-100"
onClick={async (e) => {
e.preventDefault()
e.stopPropagation()
try {
await deleteAlbum(album.id)
toast.success("Album deleted")
} catch {
toast.error("Failed to delete album")
}
}}
>
<Trash2Icon className="h-3 w-3" />
</Button>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>

View File

@@ -8,7 +8,7 @@ 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"
import { ImagePlusIcon, XIcon, CheckSquareIcon, Trash2Icon } from "lucide-react"
interface PhotoGridProps {
groups: DateGroup[]
@@ -16,6 +16,7 @@ interface PhotoGridProps {
hasMore: boolean
onLoadMore: () => void
onRemoveAsset?: (assetId: string) => void
onDeleteAssets?: (assetIds: string[]) => void
}
export function PhotoGrid({
@@ -24,6 +25,7 @@ export function PhotoGrid({
hasMore,
onLoadMore,
onRemoveAsset,
onDeleteAssets,
}: PhotoGridProps) {
const sentinelRef = useRef<HTMLDivElement>(null)
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
@@ -94,13 +96,26 @@ export function PhotoGrid({
{onRemoveAsset && selectedIds.size > 0 && (
<Button
size="sm"
variant="destructive"
variant="secondary"
onClick={() => {
selectedIds.forEach((id) => onRemoveAsset(id))
exitSelection()
}}
>
Remove
Remove from Album
</Button>
)}
{onDeleteAssets && selectedIds.size > 0 && (
<Button
size="sm"
variant="destructive"
onClick={() => {
onDeleteAssets(Array.from(selectedIds))
exitSelection()
}}
>
<Trash2Icon className="mr-1.5 h-3.5 w-3.5" />
Delete
</Button>
)}
<Button size="sm" variant="ghost" onClick={exitSelection}>

View File

@@ -36,6 +36,13 @@ export function useAlbums() {
onSuccess: () => qc.invalidateQueries({ queryKey: ["albums"] }),
})
const deleteAlbum = useMutation({
mutationFn: async (id: string) => {
await api.delete(`/albums/${id}`)
},
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 })
@@ -60,6 +67,7 @@ export function useAlbums() {
albums: query.data ?? [],
isLoading: query.isLoading,
createAlbum: create.mutateAsync,
deleteAlbum: deleteAlbum.mutateAsync,
updateAlbum: update.mutateAsync,
addEntry: addEntry.mutateAsync,
removeEntry: removeEntry.mutateAsync,