Compare commits
3 Commits
2679214cb9
...
322969c938
| Author | SHA1 | Date | |
|---|---|---|---|
| 322969c938 | |||
| c7d2cc47e1 | |||
| 997e11db78 |
@@ -11,7 +11,7 @@ import os
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import init_db
|
from app.database import init_db
|
||||||
from app.routers import photos, folders, heaps, tags, trash, library
|
from app.routers import photos, folders, heaps, tags, discard, library
|
||||||
from app.services.scanner import start_initial_scan
|
from app.services.scanner import start_initial_scan
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
@@ -64,7 +64,7 @@ app.include_router(photos.router, prefix="/api/v1/photos", tags=["photos"])
|
|||||||
app.include_router(folders.router, prefix="/api/v1/folders", tags=["folders"])
|
app.include_router(folders.router, prefix="/api/v1/folders", tags=["folders"])
|
||||||
app.include_router(heaps.router, prefix="/api/v1/heaps", tags=["heaps"])
|
app.include_router(heaps.router, prefix="/api/v1/heaps", tags=["heaps"])
|
||||||
app.include_router(tags.router, prefix="/api/v1/tags", tags=["tags"])
|
app.include_router(tags.router, prefix="/api/v1/tags", tags=["tags"])
|
||||||
app.include_router(trash.router, prefix="/api/v1/trash", tags=["trash"])
|
app.include_router(discard.router, prefix="/api/v1/discard", tags=["discard"])
|
||||||
app.include_router(library.router, prefix="/api/v1/library", tags=["library"])
|
app.include_router(library.router, prefix="/api/v1/library", tags=["library"])
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@@ -33,9 +33,10 @@ class Photo(Base):
|
|||||||
added_at = Column(DateTime, server_default=func.now())
|
added_at = Column(DateTime, server_default=func.now())
|
||||||
updated_at = Column(DateTime, onupdate=func.now())
|
updated_at = Column(DateTime, onupdate=func.now())
|
||||||
|
|
||||||
# Trash status
|
# Discard status. The DB column names stay is_trashed/trashed_at to avoid
|
||||||
is_trashed = Column(Boolean, default=False)
|
# a migration; only the Python attribute name reflects the rename.
|
||||||
trashed_at = Column(DateTime)
|
is_discarded = Column('is_trashed', Boolean, default=False)
|
||||||
|
discarded_at = Column('trashed_at', DateTime)
|
||||||
|
|
||||||
# Thumbnail paths
|
# Thumbnail paths
|
||||||
thumb_small = Column(String) # path to 240px thumb
|
thumb_small = Column(String) # path to 240px thumb
|
||||||
@@ -55,7 +56,7 @@ class Photo(Base):
|
|||||||
rating = Column(Integer, default=0) # 0-5 stars
|
rating = Column(Integer, default=0) # 0-5 stars
|
||||||
color_label = Column(String) # 'red'|'orange'|'yellow'|'green'|'blue'|'purple'|NULL
|
color_label = Column(String) # 'red'|'orange'|'yellow'|'green'|'blue'|'purple'|NULL
|
||||||
is_picked = Column(Boolean, default=False)
|
is_picked = Column(Boolean, default=False)
|
||||||
# Note: is_rejected was merged into is_trashed (a single soft "trashed"
|
# Note: is_rejected was merged into is_discarded (a single soft "discarded"
|
||||||
# concept). The DB column may still exist on legacy installs but is no
|
# concept). The DB column may still exist on legacy installs but is no
|
||||||
# longer read or written.
|
# longer read or written.
|
||||||
|
|
||||||
|
|||||||
68
backend/app/routers/discard.py
Normal file
68
backend/app/routers/discard.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""
|
||||||
|
Discard API router
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy import select, and_
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import Photo
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_discarded(db: AsyncSession = Depends(get_db)):
|
||||||
|
"""List discarded photos"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Photo).where(Photo.is_discarded == True)
|
||||||
|
)
|
||||||
|
photos = result.scalars().all()
|
||||||
|
return photos
|
||||||
|
|
||||||
|
@router.post("/restore")
|
||||||
|
async def restore_photos(photo_ids: list[str], db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Restore photos from the discard pile"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Photo).where(and_(Photo.id.in_(photo_ids), Photo.is_discarded == True))
|
||||||
|
)
|
||||||
|
photos = result.scalars().all()
|
||||||
|
|
||||||
|
for photo in photos:
|
||||||
|
photo.is_discarded = False
|
||||||
|
photo.discarded_at = None
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return {"status": "success", "restored": len(photos)}
|
||||||
|
|
||||||
|
@router.delete("/empty")
|
||||||
|
async def empty_discard(db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Permanently delete all discarded photos and unlink their files from
|
||||||
|
disk. Failures on individual files are logged but don't abort the batch.
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Photo).where(Photo.is_discarded == True)
|
||||||
|
)
|
||||||
|
photos = result.scalars().all()
|
||||||
|
|
||||||
|
deleted = 0
|
||||||
|
file_errors = 0
|
||||||
|
for photo in photos:
|
||||||
|
try:
|
||||||
|
if photo.filepath and os.path.exists(photo.filepath):
|
||||||
|
os.unlink(photo.filepath)
|
||||||
|
except OSError as e:
|
||||||
|
file_errors += 1
|
||||||
|
logger.error(f"Failed to unlink {photo.filepath}: {e}")
|
||||||
|
await db.delete(photo)
|
||||||
|
deleted += 1
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"deleted": deleted,
|
||||||
|
"file_errors": file_errors,
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ async def list_photos(
|
|||||||
rating_max: Optional[int] = Query(None, ge=0, le=5),
|
rating_max: Optional[int] = Query(None, ge=0, le=5),
|
||||||
color_label: Optional[str] = None,
|
color_label: Optional[str] = None,
|
||||||
is_picked: Optional[bool] = None,
|
is_picked: Optional[bool] = None,
|
||||||
is_trashed: Optional[bool] = False,
|
is_discarded: Optional[bool] = False,
|
||||||
heap_id: Optional[str] = None,
|
heap_id: Optional[str] = None,
|
||||||
sort: str = "taken_at",
|
sort: str = "taken_at",
|
||||||
order: str = "desc",
|
order: str = "desc",
|
||||||
@@ -93,8 +93,8 @@ async def list_photos(
|
|||||||
if is_picked is not None:
|
if is_picked is not None:
|
||||||
filters.append(Photo.is_picked == is_picked)
|
filters.append(Photo.is_picked == is_picked)
|
||||||
|
|
||||||
# Trash filter — defaults to hiding trashed photos
|
# Discard filter — defaults to hiding discarded photos
|
||||||
filters.append(Photo.is_trashed == is_trashed)
|
filters.append(Photo.is_discarded == is_discarded)
|
||||||
|
|
||||||
# Apply all filters
|
# Apply all filters
|
||||||
if filters:
|
if filters:
|
||||||
@@ -407,13 +407,13 @@ async def update_photo(
|
|||||||
return PhotoResponse.from_orm(photo)
|
return PhotoResponse.from_orm(photo)
|
||||||
|
|
||||||
@router.delete("/{photo_id}")
|
@router.delete("/{photo_id}")
|
||||||
async def trash_photo(
|
async def discard_photo(
|
||||||
photo_id: str,
|
photo_id: str,
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Soft-trash a photo: sets is_trashed=true. The file stays on disk so
|
"""Soft-discard a photo: sets is_discarded=true. The file stays on disk so
|
||||||
restore is just a flag flip. Permanent deletion happens via DELETE
|
restore is just a flag flip. Permanent deletion happens via DELETE
|
||||||
/trash/{id} or DELETE /trash/empty.
|
/discard/{id} or DELETE /discard/empty.
|
||||||
"""
|
"""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Photo).where(Photo.id == photo_id)
|
select(Photo).where(Photo.id == photo_id)
|
||||||
@@ -423,11 +423,11 @@ async def trash_photo(
|
|||||||
if not photo:
|
if not photo:
|
||||||
raise HTTPException(status_code=404, detail="Photo not found")
|
raise HTTPException(status_code=404, detail="Photo not found")
|
||||||
|
|
||||||
photo.is_trashed = True
|
photo.is_discarded = True
|
||||||
photo.trashed_at = datetime.utcnow()
|
photo.discarded_at = datetime.utcnow()
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return {"status": "success", "message": "Photo moved to trash"}
|
return {"status": "success", "message": "Photo discarded"}
|
||||||
|
|
||||||
@router.post("/bulk")
|
@router.post("/bulk")
|
||||||
async def bulk_action(
|
async def bulk_action(
|
||||||
@@ -445,14 +445,14 @@ async def bulk_action(
|
|||||||
raise HTTPException(status_code=404, detail="No photos found")
|
raise HTTPException(status_code=404, detail="No photos found")
|
||||||
|
|
||||||
# Perform action based on type
|
# Perform action based on type
|
||||||
if action.action == 'trash':
|
if action.action == 'discard':
|
||||||
for photo in photos:
|
for photo in photos:
|
||||||
photo.is_trashed = True
|
photo.is_discarded = True
|
||||||
photo.trashed_at = datetime.utcnow()
|
photo.discarded_at = datetime.utcnow()
|
||||||
elif action.action == 'restore':
|
elif action.action == 'restore':
|
||||||
for photo in photos:
|
for photo in photos:
|
||||||
photo.is_trashed = False
|
photo.is_discarded = False
|
||||||
photo.trashed_at = None
|
photo.discarded_at = None
|
||||||
elif action.action == 'set_rating':
|
elif action.action == 'set_rating':
|
||||||
for photo in photos:
|
for photo in photos:
|
||||||
photo.rating = action.value
|
photo.rating = action.value
|
||||||
@@ -462,7 +462,7 @@ async def bulk_action(
|
|||||||
elif action.action == 'pick':
|
elif action.action == 'pick':
|
||||||
for photo in photos:
|
for photo in photos:
|
||||||
photo.is_picked = True
|
photo.is_picked = True
|
||||||
photo.is_trashed = False
|
photo.is_discarded = False
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=400, detail="Invalid action")
|
raise HTTPException(status_code=400, detail="Invalid action")
|
||||||
|
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
"""
|
|
||||||
Trash API router
|
|
||||||
"""
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from sqlalchemy import select, and_
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from app.database import get_db
|
|
||||||
from app.models import Photo
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
async def list_trashed(db: AsyncSession = Depends(get_db)):
|
|
||||||
"""List trashed photos"""
|
|
||||||
result = await db.execute(
|
|
||||||
select(Photo).where(Photo.is_trashed == True)
|
|
||||||
)
|
|
||||||
photos = result.scalars().all()
|
|
||||||
return photos
|
|
||||||
|
|
||||||
@router.post("/restore")
|
|
||||||
async def restore_photos(photo_ids: list[str], db: AsyncSession = Depends(get_db)):
|
|
||||||
"""Restore photos from trash"""
|
|
||||||
result = await db.execute(
|
|
||||||
select(Photo).where(and_(Photo.id.in_(photo_ids), Photo.is_trashed == True))
|
|
||||||
)
|
|
||||||
photos = result.scalars().all()
|
|
||||||
|
|
||||||
for photo in photos:
|
|
||||||
photo.is_trashed = False
|
|
||||||
photo.trashed_at = None
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
return {"status": "success", "restored": len(photos)}
|
|
||||||
|
|
||||||
@router.delete("/empty")
|
|
||||||
async def empty_trash(db: AsyncSession = Depends(get_db)):
|
|
||||||
"""Permanently delete all trashed photos"""
|
|
||||||
result = await db.execute(
|
|
||||||
select(Photo).where(Photo.is_trashed == True)
|
|
||||||
)
|
|
||||||
photos = result.scalars().all()
|
|
||||||
|
|
||||||
for photo in photos:
|
|
||||||
await db.delete(photo)
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
return {"status": "success", "deleted": len(photos)}
|
|
||||||
@@ -29,8 +29,8 @@ class PhotoResponse(PhotoBase):
|
|||||||
file_hash: Optional[str] = None
|
file_hash: Optional[str] = None
|
||||||
added_at: datetime
|
added_at: datetime
|
||||||
updated_at: Optional[datetime] = None
|
updated_at: Optional[datetime] = None
|
||||||
is_trashed: bool = False
|
is_discarded: bool = False
|
||||||
trashed_at: Optional[datetime] = None
|
discarded_at: Optional[datetime] = None
|
||||||
thumb_small: Optional[str] = None
|
thumb_small: Optional[str] = None
|
||||||
thumb_medium: Optional[str] = None
|
thumb_medium: Optional[str] = None
|
||||||
thumb_large: Optional[str] = None
|
thumb_large: Optional[str] = None
|
||||||
@@ -52,7 +52,7 @@ class PhotoUpdate(BaseModel):
|
|||||||
rating: Optional[int] = Field(None, ge=0, le=5)
|
rating: Optional[int] = Field(None, ge=0, le=5)
|
||||||
color_label: Optional[str] = None
|
color_label: Optional[str] = None
|
||||||
is_picked: Optional[bool] = None
|
is_picked: Optional[bool] = None
|
||||||
is_trashed: Optional[bool] = None
|
is_discarded: Optional[bool] = None
|
||||||
taken_at: Optional[datetime] = None
|
taken_at: Optional[datetime] = None
|
||||||
|
|
||||||
class PhotoListResponse(BaseModel):
|
class PhotoListResponse(BaseModel):
|
||||||
@@ -66,5 +66,5 @@ class PhotoListResponse(BaseModel):
|
|||||||
class BulkAction(BaseModel):
|
class BulkAction(BaseModel):
|
||||||
"""Bulk action on photos"""
|
"""Bulk action on photos"""
|
||||||
ids: List[str]
|
ids: List[str]
|
||||||
action: str # 'trash', 'restore', 'delete_permanent', 'move', 'copy', 'add_tag', 'remove_tag', 'set_rating', 'set_color', 'pick', 'reject'
|
action: str # 'discard', 'restore', 'delete_permanent', 'move', 'copy', 'add_tag', 'remove_tag', 'set_rating', 'set_color', 'pick'
|
||||||
value: Optional[Any] = None # For actions that need a value (rating, color, tag_id, folder_id)
|
value: Optional[Any] = None # For actions that need a value (rating, color, tag_id, folder_id)
|
||||||
@@ -292,7 +292,7 @@ async def handle_file_deletion(filepath: str):
|
|||||||
|
|
||||||
if photo:
|
if photo:
|
||||||
# Mark as missing or delete from database
|
# Mark as missing or delete from database
|
||||||
photo.is_trashed = True
|
photo.is_discarded = True
|
||||||
photo.trashed_at = datetime.utcnow()
|
photo.discarded_at = datetime.utcnow()
|
||||||
await session.commit()
|
await session.commit()
|
||||||
logger.info(f"Marked photo as trashed: {filepath}")
|
logger.info(f"Marked photo as discarded: {filepath}")
|
||||||
@@ -9,6 +9,7 @@ import { KeyboardHints } from './components/KeyboardHints'
|
|||||||
import { PreviewView } from './components/preview/PreviewView'
|
import { PreviewView } from './components/preview/PreviewView'
|
||||||
import { FilterBar } from './components/filter/FilterBar'
|
import { FilterBar } from './components/filter/FilterBar'
|
||||||
import { ActiveFilterChips } from './components/filter/ActiveFilterChips'
|
import { ActiveFilterChips } from './components/filter/ActiveFilterChips'
|
||||||
|
import { DiscardActionBar } from './components/discard/DiscardActionBar'
|
||||||
import { usePhotoStore } from './store/photoStore'
|
import { usePhotoStore } from './store/photoStore'
|
||||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
||||||
import { useFilterUrlSync } from './hooks/useFilterUrlSync'
|
import { useFilterUrlSync } from './hooks/useFilterUrlSync'
|
||||||
@@ -52,6 +53,7 @@ function App() {
|
|||||||
<TopBar />
|
<TopBar />
|
||||||
<FilterBar />
|
<FilterBar />
|
||||||
<ActiveFilterChips />
|
<ActiveFilterChips />
|
||||||
|
<DiscardActionBar />
|
||||||
<KeyboardHints />
|
<KeyboardHints />
|
||||||
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
|||||||
@@ -12,15 +12,15 @@ export function KeyboardHints() {
|
|||||||
? [
|
? [
|
||||||
{ key: '1-5', action: 'Rate' },
|
{ key: '1-5', action: 'Rate' },
|
||||||
{ key: 'P', action: 'Pick' },
|
{ key: 'P', action: 'Pick' },
|
||||||
{ key: 'X', action: 'Trash' },
|
{ key: 'X', action: 'Discard' },
|
||||||
{ key: 'E / Space', action: 'Preview' },
|
{ key: 'Space', action: 'Preview' },
|
||||||
{ key: 'Esc', action: 'Deselect' },
|
{ key: 'Esc', action: 'Deselect' },
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{ key: '↑↓←→', action: 'Navigate' },
|
{ key: '↑↓←→', action: 'Navigate' },
|
||||||
{ key: 'Click', action: 'Select' },
|
{ key: 'Click', action: 'Select' },
|
||||||
{ key: 'Shift+Click', action: 'Range' },
|
{ key: 'Shift+Click', action: 'Range' },
|
||||||
{ key: 'E / Space', action: 'Preview' },
|
{ key: 'Space', action: 'Preview' },
|
||||||
{ key: '\\', action: 'Filters' },
|
{ key: '\\', action: 'Filters' },
|
||||||
{ key: '/', action: 'Search' },
|
{ key: '/', action: 'Search' },
|
||||||
]
|
]
|
||||||
|
|||||||
75
frontend/src/components/dialogs/ConfirmDialog.tsx
Normal file
75
frontend/src/components/dialogs/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
interface ConfirmDialogProps {
|
||||||
|
isOpen: boolean
|
||||||
|
title: string
|
||||||
|
message: React.ReactNode
|
||||||
|
confirmLabel?: string
|
||||||
|
cancelLabel?: string
|
||||||
|
/** When true, the confirm button uses the destructive accent. */
|
||||||
|
destructive?: boolean
|
||||||
|
onConfirm: () => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tiny modal-confirmation dialog. Mirrors the AddSourceFolderDialog overlay
|
||||||
|
* pattern (custom fixed inset-0 backdrop, no shadcn Dialog dep). Esc closes.
|
||||||
|
*/
|
||||||
|
export function ConfirmDialog({
|
||||||
|
isOpen,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmLabel = 'Confirm',
|
||||||
|
cancelLabel = 'Cancel',
|
||||||
|
destructive = false,
|
||||||
|
onConfirm,
|
||||||
|
onClose,
|
||||||
|
}: ConfirmDialogProps) {
|
||||||
|
// Esc to close.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handler)
|
||||||
|
return () => window.removeEventListener('keydown', handler)
|
||||||
|
}, [isOpen, onClose])
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||||
|
<div className="relative z-10 w-96 rounded-lg border border-border bg-surface p-5 shadow-2xl">
|
||||||
|
<h2 className="mb-2 text-base font-semibold text-text">{title}</h2>
|
||||||
|
<div className="mb-4 text-sm text-text-muted">{message}</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded border border-border px-3 py-1.5 text-sm text-text hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
className={clsx(
|
||||||
|
'rounded px-3 py-1.5 text-sm font-medium text-white',
|
||||||
|
destructive
|
||||||
|
? 'bg-reject hover:bg-reject/80'
|
||||||
|
: 'bg-primary hover:bg-primary/80'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
112
frontend/src/components/discard/DiscardActionBar.tsx
Normal file
112
frontend/src/components/discard/DiscardActionBar.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { RotateCcw, Trash2 } from 'lucide-react'
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { usePhotoStore } from '../../store/photoStore'
|
||||||
|
import { useFilterStore } from '../../store/filterStore'
|
||||||
|
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
||||||
|
import { discard as discardApi } from '../../services/api'
|
||||||
|
import { toast } from '../ToastContainer'
|
||||||
|
import { ConfirmDialog } from '../dialogs/ConfirmDialog'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-of-timeline bar visible only when the discarded filter is active.
|
||||||
|
* Shows a count, lets the user restore the current selection, and lets them
|
||||||
|
* permanently empty the discard pile (with confirmation).
|
||||||
|
*/
|
||||||
|
export function DiscardActionBar() {
|
||||||
|
const flag = useFilterStore((s) => s.flag)
|
||||||
|
const selectedPhotos = usePhotoStore((s) => s.selectedPhotos)
|
||||||
|
const clearSelection = usePhotoStore((s) => s.clearSelection)
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { data: photos = [] } = usePhotosQuery()
|
||||||
|
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||||
|
|
||||||
|
const restoreMutation = useMutation({
|
||||||
|
mutationFn: (ids: string[]) => discardApi.restore(ids),
|
||||||
|
onSuccess: (_, ids) => {
|
||||||
|
toast.success('Restored', `${ids.length} photo${ids.length > 1 ? 's' : ''} restored`)
|
||||||
|
clearSelection()
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
|
},
|
||||||
|
onError: (e: any) => toast.error('Restore failed', e.message || 'Unknown error'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const emptyMutation = useMutation({
|
||||||
|
mutationFn: () => discardApi.empty(),
|
||||||
|
onSuccess: (data: any) => {
|
||||||
|
const count = data?.deleted ?? 0
|
||||||
|
const errors = data?.file_errors ?? 0
|
||||||
|
if (errors > 0) {
|
||||||
|
toast.error(
|
||||||
|
`Emptied with ${errors} error${errors > 1 ? 's' : ''}`,
|
||||||
|
`${count} record${count > 1 ? 's' : ''} deleted; some files could not be removed`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
toast.success('Discard pile emptied', `${count} photo${count > 1 ? 's' : ''} permanently deleted`)
|
||||||
|
}
|
||||||
|
clearSelection()
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
|
setConfirmOpen(false)
|
||||||
|
},
|
||||||
|
onError: (e: any) => toast.error('Empty failed', e.message || 'Unknown error'),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (flag !== 'discarded') return null
|
||||||
|
|
||||||
|
const total = photos.length
|
||||||
|
const selected = selectedPhotos.length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between gap-3 border-b border-border bg-reject/10 px-4 py-2 text-sm">
|
||||||
|
<div className="flex items-center gap-2 text-text">
|
||||||
|
<Trash2 className="h-4 w-4 text-reject" />
|
||||||
|
<span className="font-medium">Discarded</span>
|
||||||
|
<span className="text-text-muted">
|
||||||
|
{total} photo{total === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{selected > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => restoreMutation.mutate(selectedPhotos)}
|
||||||
|
disabled={restoreMutation.isPending}
|
||||||
|
className="flex items-center gap-1.5 rounded bg-surface-2 px-3 py-1 text-text hover:bg-surface-offset disabled:opacity-50"
|
||||||
|
title="Restore selected (U)"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
|
Restore {selected}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmOpen(true)}
|
||||||
|
disabled={total === 0 || emptyMutation.isPending}
|
||||||
|
className="flex items-center gap-1.5 rounded bg-reject/20 px-3 py-1 text-reject hover:bg-reject/30 disabled:opacity-50"
|
||||||
|
title="Permanently delete all discarded photos and files"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
Empty discard pile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={confirmOpen}
|
||||||
|
title="Empty discard pile?"
|
||||||
|
message={
|
||||||
|
<>
|
||||||
|
This will <strong className="text-text">permanently delete</strong>{' '}
|
||||||
|
{total} photo{total === 1 ? '' : 's'} and remove the file
|
||||||
|
{total === 1 ? '' : 's'} from disk. This cannot be undone.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
confirmLabel="Empty pile"
|
||||||
|
destructive
|
||||||
|
onConfirm={() => emptyMutation.mutate()}
|
||||||
|
onClose={() => setConfirmOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ const COLOR_LABELS: { value: ColorLabel; className: string }[] = [
|
|||||||
const FLAG_OPTIONS: { value: FlagFilter; label: string }[] = [
|
const FLAG_OPTIONS: { value: FlagFilter; label: string }[] = [
|
||||||
{ value: 'any', label: 'Any' },
|
{ value: 'any', label: 'Any' },
|
||||||
{ value: 'picked', label: 'Picked' },
|
{ value: 'picked', label: 'Picked' },
|
||||||
{ value: 'trashed', label: 'Trashed' },
|
{ value: 'discarded', label: 'Discarded' },
|
||||||
{ value: 'unflagged', label: 'Unflagged' },
|
{ value: 'unflagged', label: 'Unflagged' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { AddSourceFolderDialog } from '../dialogs/AddSourceFolderDialog'
|
|||||||
import { sourceFolders, library } from '../../services/api'
|
import { sourceFolders, library } from '../../services/api'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from '../ToastContainer'
|
import { toast } from '../ToastContainer'
|
||||||
|
import { useFilterStore } from '../../store/filterStore'
|
||||||
|
|
||||||
interface TreeItem {
|
interface TreeItem {
|
||||||
id: string
|
id: string
|
||||||
@@ -33,8 +34,34 @@ export function LeftSidebar() {
|
|||||||
const [selectedItem, setSelectedItem] = useState<string | null>('all-photos')
|
const [selectedItem, setSelectedItem] = useState<string | null>('all-photos')
|
||||||
const [showAddFolderDialog, setShowAddFolderDialog] = useState(false)
|
const [showAddFolderDialog, setShowAddFolderDialog] = useState(false)
|
||||||
const [isScanning, setIsScanning] = useState(false)
|
const [isScanning, setIsScanning] = useState(false)
|
||||||
|
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const clearAllFilters = useFilterStore((s) => s.clearAll)
|
||||||
|
const setRatingMin = useFilterStore((s) => s.setRatingMin)
|
||||||
|
const setFlag = useFilterStore((s) => s.setFlag)
|
||||||
|
|
||||||
|
// Map a library tree id to a filter-store mutation. Each "virtual node" in
|
||||||
|
// the library tree is just a saved filter preset.
|
||||||
|
const applyLibraryNode = (id: string) => {
|
||||||
|
switch (id) {
|
||||||
|
case 'all-photos':
|
||||||
|
clearAllFilters()
|
||||||
|
break
|
||||||
|
case 'rated':
|
||||||
|
clearAllFilters()
|
||||||
|
setRatingMin(1)
|
||||||
|
break
|
||||||
|
case 'flagged':
|
||||||
|
clearAllFilters()
|
||||||
|
setFlag('picked')
|
||||||
|
break
|
||||||
|
case 'discarded':
|
||||||
|
clearAllFilters()
|
||||||
|
setFlag('discarded')
|
||||||
|
break
|
||||||
|
// 'by-date' is purely visual until we add a date-grouping UI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch folders from API
|
// Fetch folders from API
|
||||||
const { data: foldersData, refetch: refetchFolders } = useQuery({
|
const { data: foldersData, refetch: refetchFolders } = useQuery({
|
||||||
@@ -111,7 +138,7 @@ export function LeftSidebar() {
|
|||||||
{ id: 'by-date', label: 'By Date', icon: <Calendar className="h-4 w-4" /> },
|
{ id: 'by-date', label: 'By Date', icon: <Calendar className="h-4 w-4" /> },
|
||||||
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: 0 },
|
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: 0 },
|
||||||
{ id: 'flagged', label: 'Flagged', icon: <Flag className="h-4 w-4" />, count: 0 },
|
{ id: 'flagged', label: 'Flagged', icon: <Flag className="h-4 w-4" />, count: 0 },
|
||||||
{ id: 'trash', label: 'Trash', icon: <Trash2 className="h-4 w-4" />, count: 0 },
|
{ id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: 0 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -152,6 +179,8 @@ export function LeftSidebar() {
|
|||||||
setSelectedItem(item.id)
|
setSelectedItem(item.id)
|
||||||
if (hasChildren) {
|
if (hasChildren) {
|
||||||
toggleExpanded(item.id)
|
toggleExpanded(item.id)
|
||||||
|
} else {
|
||||||
|
applyLibraryNode(item.id)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ interface PhotoDetails {
|
|||||||
taken_at: string | null
|
taken_at: string | null
|
||||||
rating: number
|
rating: number
|
||||||
is_picked: boolean
|
is_picked: boolean
|
||||||
is_trashed: boolean
|
is_discarded: boolean
|
||||||
exif_json: string | null
|
exif_json: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ export function RightSidebar() {
|
|||||||
mutationFn: (data: {
|
mutationFn: (data: {
|
||||||
rating?: number
|
rating?: number
|
||||||
is_picked?: boolean
|
is_picked?: boolean
|
||||||
is_trashed?: boolean
|
is_discarded?: boolean
|
||||||
}) => photosApi.update(activePhotoId!, data),
|
}) => photosApi.update(activePhotoId!, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['photo', activePhotoId] })
|
queryClient.invalidateQueries({ queryKey: ['photo', activePhotoId] })
|
||||||
@@ -132,7 +132,7 @@ export function RightSidebar() {
|
|||||||
const multipleSelected = selectedPhotos.length > 1
|
const multipleSelected = selectedPhotos.length > 1
|
||||||
const rating = photo?.rating ?? 0
|
const rating = photo?.rating ?? 0
|
||||||
const isPicked = photo?.is_picked ?? false
|
const isPicked = photo?.is_picked ?? false
|
||||||
const isTrashed = photo?.is_trashed ?? false
|
const isDiscarded = photo?.is_discarded ?? false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-surface">
|
<div className="flex h-full flex-col bg-surface">
|
||||||
@@ -187,7 +187,7 @@ export function RightSidebar() {
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
updateMutation.mutate({
|
updateMutation.mutate({
|
||||||
is_picked: !isPicked,
|
is_picked: !isPicked,
|
||||||
is_trashed: false,
|
is_discarded: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -203,19 +203,19 @@ export function RightSidebar() {
|
|||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
updateMutation.mutate({
|
updateMutation.mutate({
|
||||||
is_trashed: !isTrashed,
|
is_discarded: !isDiscarded,
|
||||||
is_picked: false,
|
is_picked: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
|
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
|
||||||
isTrashed
|
isDiscarded
|
||||||
? 'bg-reject/20 text-reject'
|
? 'bg-reject/20 text-reject'
|
||||||
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
|
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3" />
|
<Trash2 className="h-3 w-3" />
|
||||||
Trash
|
Discard
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -58,18 +58,18 @@ export function TopBar() {
|
|||||||
const selectedCount = selectedPhotos.length
|
const selectedCount = selectedPhotos.length
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
// Mutation for moving photos to trash
|
// Mutation for discarding selected photos
|
||||||
const trashPhotosMutation = useMutation({
|
const discardPhotosMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
await photos.bulkUpdate(selectedPhotos, { trash: true })
|
await photos.bulkUpdate(selectedPhotos, { discard: true })
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Moved to Trash', `${selectedCount} photo${selectedCount > 1 ? 's' : ''} moved to trash`)
|
toast.success('Discarded', `${selectedCount} photo${selectedCount > 1 ? 's' : ''} discarded`)
|
||||||
clearSelection()
|
clearSelection()
|
||||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
toast.error('Failed to Move to Trash', error.message || 'An error occurred')
|
toast.error('Failed to discard', error.message || 'An error occurred')
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -96,13 +96,13 @@ export function TopBar() {
|
|||||||
{selectedCount} selected
|
{selectedCount} selected
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => trashPhotosMutation.mutate()}
|
onClick={() => discardPhotosMutation.mutate()}
|
||||||
disabled={trashPhotosMutation.isPending}
|
disabled={discardPhotosMutation.isPending}
|
||||||
className="flex items-center gap-1 rounded bg-reject/20 px-2 py-0.5 text-sm text-reject hover:bg-reject/30 disabled:opacity-50"
|
className="flex items-center gap-1 rounded bg-reject/20 px-2 py-0.5 text-sm text-reject hover:bg-reject/30 disabled:opacity-50"
|
||||||
title="Move to trash"
|
title="Discard"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
Trash
|
Discard
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ export function PhotoThumbnail({ photo, size, isSelected, onClick, onDoubleClick
|
|||||||
{photo.is_picked && (
|
{photo.is_picked && (
|
||||||
<Check className="h-4 w-4 text-pick" />
|
<Check className="h-4 w-4 text-pick" />
|
||||||
)}
|
)}
|
||||||
{photo.is_trashed && (
|
{photo.is_discarded && (
|
||||||
<Trash2 className="h-4 w-4 text-reject" />
|
<Trash2 className="h-4 w-4 text-reject" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const ALLOWED_COLORS: ColorLabel[] = [
|
|||||||
'blue',
|
'blue',
|
||||||
'purple',
|
'purple',
|
||||||
]
|
]
|
||||||
const ALLOWED_FLAGS: FlagFilter[] = ['any', 'picked', 'trashed', 'unflagged']
|
const ALLOWED_FLAGS: FlagFilter[] = ['any', 'picked', 'discarded', 'unflagged']
|
||||||
|
|
||||||
function parseUrl(): Partial<FilterState> {
|
function parseUrl(): Partial<FilterState> {
|
||||||
const sp = new URLSearchParams(window.location.search)
|
const sp = new URLSearchParams(window.location.search)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ interface KeyboardShortcutsProps {
|
|||||||
interface PhotoUpdate {
|
interface PhotoUpdate {
|
||||||
rating?: number
|
rating?: number
|
||||||
is_picked?: boolean
|
is_picked?: boolean
|
||||||
is_trashed?: boolean
|
is_discarded?: boolean
|
||||||
color_label?: string | null
|
color_label?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,8 +75,8 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
|||||||
useHotkeys('/', focusSearch, HK_OPTS)
|
useHotkeys('/', focusSearch, HK_OPTS)
|
||||||
useHotkeys('mod+f', focusSearch, HK_OPTS)
|
useHotkeys('mod+f', focusSearch, HK_OPTS)
|
||||||
|
|
||||||
// E and Space both toggle the preview view (open from grid, close from
|
// Space toggles the preview view (open from grid, close from preview).
|
||||||
// preview). Double-click on a thumbnail does the same.
|
// Double-click on a thumbnail does the same.
|
||||||
const openPreviewFromGrid = () => {
|
const openPreviewFromGrid = () => {
|
||||||
const id = usePhotoStore.getState().activePhotoId ?? getFirstPhotoId?.() ?? null
|
const id = usePhotoStore.getState().activePhotoId ?? getFirstPhotoId?.() ?? null
|
||||||
if (id) openPreview(id)
|
if (id) openPreview(id)
|
||||||
@@ -87,7 +87,6 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
|||||||
else openPreviewFromGrid()
|
else openPreviewFromGrid()
|
||||||
}
|
}
|
||||||
|
|
||||||
useHotkeys('e', togglePreview, HK_OPTS, [isPreview, getFirstPhotoId])
|
|
||||||
useHotkeys('space', togglePreview, HK_OPTS, [isPreview, getFirstPhotoId])
|
useHotkeys('space', togglePreview, HK_OPTS, [isPreview, getFirstPhotoId])
|
||||||
|
|
||||||
// ── Culling shortcuts (work in both grid and preview) ────────────────────
|
// ── Culling shortcuts (work in both grid and preview) ────────────────────
|
||||||
@@ -104,24 +103,24 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
|||||||
|
|
||||||
useHotkeys('0', () => updateActive({ rating: 0 }), HK_OPTS)
|
useHotkeys('0', () => updateActive({ rating: 0 }), HK_OPTS)
|
||||||
|
|
||||||
// Pick / trash / unflag. Trash is the merged "rejected" concept — a soft
|
// Pick / discard / unflag. Discard is a soft flag that hides the photo
|
||||||
// flag that hides the photo from the default timeline view; restore via
|
// from the default timeline view; restore via the Discarded view (or the
|
||||||
// the trash view (or the U shortcut).
|
// U shortcut).
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
'p',
|
'p',
|
||||||
() => updateActive({ is_picked: true, is_trashed: false }),
|
() => updateActive({ is_picked: true, is_discarded: false }),
|
||||||
HK_OPTS
|
HK_OPTS
|
||||||
)
|
)
|
||||||
|
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
'x',
|
'x',
|
||||||
() => updateActive({ is_trashed: true, is_picked: false }),
|
() => updateActive({ is_discarded: true, is_picked: false }),
|
||||||
HK_OPTS
|
HK_OPTS
|
||||||
)
|
)
|
||||||
|
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
'u',
|
'u',
|
||||||
() => updateActive({ is_picked: false, is_trashed: false }),
|
() => updateActive({ is_picked: false, is_discarded: false }),
|
||||||
HK_OPTS
|
HK_OPTS
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export const photos = {
|
|||||||
rating?: number
|
rating?: number
|
||||||
flag?: string
|
flag?: string
|
||||||
heap_id?: string
|
heap_id?: string
|
||||||
trash?: boolean
|
discard?: boolean
|
||||||
}) => {
|
}) => {
|
||||||
const response = await api.post('/photos/bulk', {
|
const response = await api.post('/photos/bulk', {
|
||||||
photo_ids: photoIds,
|
photo_ids: photoIds,
|
||||||
@@ -169,22 +169,22 @@ export const tags = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trash API
|
// Discard API
|
||||||
export const trash = {
|
export const discard = {
|
||||||
list: async () => {
|
list: async () => {
|
||||||
const response = await api.get('/trash')
|
const response = await api.get('/discard')
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
restore: async (photoIds: string[]) => {
|
restore: async (photoIds: string[]) => {
|
||||||
const response = await api.post('/trash/restore', {
|
const response = await api.post('/discard/restore', {
|
||||||
photo_ids: photoIds,
|
photo_ids: photoIds,
|
||||||
})
|
})
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
empty: async () => {
|
empty: async () => {
|
||||||
const response = await api.delete('/trash/empty')
|
const response = await api.delete('/discard/empty')
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { create } from 'zustand'
|
|||||||
|
|
||||||
export type MediaType = 'photo' | 'video' | 'raw' | 'heic'
|
export type MediaType = 'photo' | 'video' | 'raw' | 'heic'
|
||||||
export type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple'
|
export type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple'
|
||||||
export type FlagFilter = 'any' | 'picked' | 'trashed' | 'unflagged'
|
export type FlagFilter = 'any' | 'picked' | 'discarded' | 'unflagged'
|
||||||
|
|
||||||
export interface FilterState {
|
export interface FilterState {
|
||||||
q: string
|
q: string
|
||||||
@@ -77,7 +77,7 @@ export function filtersToParams(f: FilterState): Record<string, string | number>
|
|||||||
if (f.ratingMin > 0) params.rating_min = f.ratingMin
|
if (f.ratingMin > 0) params.rating_min = f.ratingMin
|
||||||
if (f.colorLabel) params.color_label = f.colorLabel
|
if (f.colorLabel) params.color_label = f.colorLabel
|
||||||
if (f.flag === 'picked') params.is_picked = 'true'
|
if (f.flag === 'picked') params.is_picked = 'true'
|
||||||
else if (f.flag === 'trashed') params.is_trashed = 'true'
|
else if (f.flag === 'discarded') params.is_discarded = 'true'
|
||||||
else if (f.flag === 'unflagged') params.is_picked = 'false'
|
else if (f.flag === 'unflagged') params.is_picked = 'false'
|
||||||
return params
|
return params
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export interface Photo {
|
|||||||
taken_at: string | null
|
taken_at: string | null
|
||||||
rating: number
|
rating: number
|
||||||
is_picked: boolean
|
is_picked: boolean
|
||||||
is_trashed: boolean
|
is_discarded: boolean
|
||||||
file_hash: string
|
file_hash: string
|
||||||
thumb_small?: string
|
thumb_small?: string
|
||||||
thumb_medium?: string
|
thumb_medium?: string
|
||||||
|
|||||||
Reference in New Issue
Block a user