Compare commits
12 Commits
bd904aca36
...
e7d62c29e1
| Author | SHA1 | Date | |
|---|---|---|---|
| e7d62c29e1 | |||
| 8e00dd40f0 | |||
| 8db4242503 | |||
| dea19e5c23 | |||
| 5a0f9ff592 | |||
| a56062d353 | |||
| 7a0f738aa8 | |||
| d7f953d0a9 | |||
| 30e0900e49 | |||
| c01c3b02ce | |||
| 4e5b2cabf6 | |||
| f05ae77ef0 |
@@ -1,24 +1,48 @@
|
|||||||
"""
|
"""
|
||||||
Folders API router. Source roots themselves are config-driven (PHOTO_DIRS
|
Folders API router. Source roots themselves are config-driven (PHOTO_DIRS
|
||||||
in .env → backend bootstrap on startup) — adding or removing one is a
|
in .env → backend bootstrap on startup) — adding or removing one is a
|
||||||
docker-compose change. The UI can read the list, trigger a manual rescan,
|
docker-compose change. Sub-folders inside a source root can be created,
|
||||||
and rename the display label, but it can't change the on-disk path.
|
renamed, and deleted from the UI; those changes are mirrored to disk.
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
import logging
|
||||||
from pydantic import BaseModel
|
|
||||||
from sqlalchemy import select, func
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select, func, update as sql_update, delete as sql_delete
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import Folder, SourceRoot, Photo
|
from app.models import Folder, SourceRoot, Photo
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
class FolderRename(BaseModel):
|
class FolderRename(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class FolderCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
parent_id: str # Folder.id (NOT a SourceRoot id)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_folder_name(name: str) -> str:
|
||||||
|
"""Trim + sanity-check a folder name. Rejects names that contain a
|
||||||
|
path separator or that resolve to a parent traversal — those would
|
||||||
|
let the user escape the parent directory through this endpoint.
|
||||||
|
"""
|
||||||
|
name = (name or '').strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=400, detail="Name cannot be empty")
|
||||||
|
if '/' in name or '\\' in name or name in ('.', '..'):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid folder name")
|
||||||
|
return name
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def get_folders(db: AsyncSession = Depends(get_db)):
|
async def get_folders(db: AsyncSession = Depends(get_db)):
|
||||||
"""Get all source folders"""
|
"""Get all source folders"""
|
||||||
@@ -161,20 +185,242 @@ async def rename_folder(
|
|||||||
body: FolderRename,
|
body: FolderRename,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Rename a source root's display label. Does NOT touch the on-disk
|
"""Rename a folder. Two cases:
|
||||||
path — that's controlled by the docker mount."""
|
|
||||||
name = (body.name or '').strip()
|
|
||||||
if not name:
|
|
||||||
raise HTTPException(status_code=400, detail="Name cannot be empty")
|
|
||||||
|
|
||||||
result = await db.execute(select(SourceRoot).where(SourceRoot.id == folder_id))
|
- SourceRoot id → just change the display label. The on-disk path
|
||||||
source_root = result.scalar_one_or_none()
|
is owned by the docker mount and never moves.
|
||||||
if not source_root:
|
- Folder id → rename the directory on disk AND update every
|
||||||
raise HTTPException(status_code=404, detail="Source folder not found")
|
descendant Folder.path + Photo.filepath that
|
||||||
|
lived under the old prefix. Refuses to rename
|
||||||
|
the source-root folder itself (= the row that
|
||||||
|
matches the SourceRoot.path) because that would
|
||||||
|
require renaming the docker mount.
|
||||||
|
"""
|
||||||
|
name = _validate_folder_name(body.name)
|
||||||
|
|
||||||
|
# Try SourceRoot first (display-only rename).
|
||||||
|
sr_result = await db.execute(
|
||||||
|
select(SourceRoot).where(SourceRoot.id == folder_id)
|
||||||
|
)
|
||||||
|
source_root = sr_result.scalar_one_or_none()
|
||||||
|
if source_root:
|
||||||
|
source_root.name = name
|
||||||
|
await db.commit()
|
||||||
|
return {
|
||||||
|
"id": source_root.id,
|
||||||
|
"name": source_root.name,
|
||||||
|
"path": source_root.path,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Otherwise it's a Folder row.
|
||||||
|
folder_result = await db.execute(select(Folder).where(Folder.id == folder_id))
|
||||||
|
folder = folder_result.scalar_one_or_none()
|
||||||
|
if not folder:
|
||||||
|
raise HTTPException(status_code=404, detail="Folder not found")
|
||||||
|
|
||||||
|
# Refuse to rename the bare source root mount through here.
|
||||||
|
sr_check = await db.execute(
|
||||||
|
select(SourceRoot).where(SourceRoot.id == folder.source_root_id)
|
||||||
|
)
|
||||||
|
sr = sr_check.scalar_one_or_none()
|
||||||
|
if sr and os.path.normpath(folder.path) == os.path.normpath(sr.path):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Cannot rename the source root mount; rename the docker mount instead.",
|
||||||
|
)
|
||||||
|
|
||||||
|
old_path = os.path.normpath(folder.path).rstrip(os.sep)
|
||||||
|
parent_dir = os.path.dirname(old_path)
|
||||||
|
new_path = os.path.join(parent_dir, name)
|
||||||
|
|
||||||
|
if os.path.exists(new_path):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"A folder named '{name}' already exists here",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
shutil.move(old_path, new_path)
|
||||||
|
except OSError as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Rename failed: {e}")
|
||||||
|
|
||||||
|
# Update folder paths: this row + every descendant. SQLite REPLACE
|
||||||
|
# rewrites the prefix; we use the trailing separator on the LIKE
|
||||||
|
# pattern so a folder named "foo" doesn't accidentally match "foobar".
|
||||||
|
await db.execute(
|
||||||
|
sql_update(Folder)
|
||||||
|
.where(Folder.id == folder.id)
|
||||||
|
.values(path=new_path, name=name)
|
||||||
|
)
|
||||||
|
descendant_prefix = old_path + os.sep
|
||||||
|
descendants = await db.execute(
|
||||||
|
select(Folder).where(Folder.path.like(descendant_prefix + '%'))
|
||||||
|
)
|
||||||
|
for d in descendants.scalars().all():
|
||||||
|
d.path = new_path + d.path[len(old_path):]
|
||||||
|
|
||||||
|
# Update every photo whose filepath lives under the old prefix.
|
||||||
|
photos_result = await db.execute(
|
||||||
|
select(Photo).where(Photo.filepath.like(descendant_prefix + '%'))
|
||||||
|
)
|
||||||
|
for p in photos_result.scalars().all():
|
||||||
|
p.filepath = new_path + p.filepath[len(old_path):]
|
||||||
|
# Photos directly inside this folder (not in a subdir) won't match
|
||||||
|
# the descendant_prefix LIKE if their old path was old_path + '/file'
|
||||||
|
# — actually they DO match, since 'oldpath/file' starts with
|
||||||
|
# 'oldpath/'. So the loop above already covers them.
|
||||||
|
|
||||||
source_root.name = name
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"id": source_root.id, "name": source_root.name, "path": source_root.path}
|
return {
|
||||||
|
"id": folder.id,
|
||||||
|
"name": folder.name,
|
||||||
|
"path": folder.path,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=201)
|
||||||
|
async def create_folder(body: FolderCreate, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Create a new sub-folder under an existing Folder. Mirrors the
|
||||||
|
create to disk so the next scan sees it. Body: { name, parent_id }.
|
||||||
|
parent_id MUST be an existing Folder row id (any descendant of a
|
||||||
|
source root); creating a brand-new top-level mount is a docker
|
||||||
|
operation, not a UI one.
|
||||||
|
"""
|
||||||
|
name = _validate_folder_name(body.name)
|
||||||
|
|
||||||
|
parent_result = await db.execute(
|
||||||
|
select(Folder).where(Folder.id == body.parent_id)
|
||||||
|
)
|
||||||
|
parent = parent_result.scalar_one_or_none()
|
||||||
|
if not parent:
|
||||||
|
raise HTTPException(status_code=404, detail="Parent folder not found")
|
||||||
|
|
||||||
|
new_path = os.path.join(parent.path, name)
|
||||||
|
if os.path.exists(new_path):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"A folder named '{name}' already exists here",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.makedirs(new_path, exist_ok=False)
|
||||||
|
except OSError as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Create failed: {e}")
|
||||||
|
|
||||||
|
new_folder = Folder(
|
||||||
|
name=name,
|
||||||
|
path=new_path,
|
||||||
|
source_root_id=parent.source_root_id,
|
||||||
|
photo_count=0,
|
||||||
|
)
|
||||||
|
db.add(new_folder)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(new_folder)
|
||||||
|
return {
|
||||||
|
"id": new_folder.id,
|
||||||
|
"name": new_folder.name,
|
||||||
|
"path": new_folder.path,
|
||||||
|
"parent_id": parent.id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{folder_id}")
|
||||||
|
async def delete_folder(
|
||||||
|
folder_id: str,
|
||||||
|
mode: Literal['discard', 'permanent'] = Query('discard'),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Delete a folder. Behavior depends on mode:
|
||||||
|
|
||||||
|
- mode=discard (default): mark every photo whose filepath lives
|
||||||
|
under this folder as is_discarded=true. The folder row, its
|
||||||
|
descendant rows, and the on-disk directory are LEFT INTACT —
|
||||||
|
the user can still recover photos from the discard pile, and
|
||||||
|
a re-scan won't double-import them.
|
||||||
|
|
||||||
|
- mode=permanent: unlink every photo file under this folder,
|
||||||
|
remove the photo + folder rows from the DB, and rmtree the
|
||||||
|
on-disk directory. Irreversible.
|
||||||
|
|
||||||
|
Refuses to delete the bare source-root mount in either mode (deleting
|
||||||
|
the docker mount through the UI would be a footgun).
|
||||||
|
"""
|
||||||
|
folder_result = await db.execute(select(Folder).where(Folder.id == folder_id))
|
||||||
|
folder = folder_result.scalar_one_or_none()
|
||||||
|
if not folder:
|
||||||
|
raise HTTPException(status_code=404, detail="Folder not found")
|
||||||
|
|
||||||
|
sr_check = await db.execute(
|
||||||
|
select(SourceRoot).where(SourceRoot.id == folder.source_root_id)
|
||||||
|
)
|
||||||
|
sr = sr_check.scalar_one_or_none()
|
||||||
|
if sr and os.path.normpath(folder.path) == os.path.normpath(sr.path):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Cannot delete the source root mount through the UI",
|
||||||
|
)
|
||||||
|
|
||||||
|
folder_path = os.path.normpath(folder.path).rstrip(os.sep)
|
||||||
|
descendant_prefix = folder_path + os.sep
|
||||||
|
|
||||||
|
# Collect every photo under this folder OR any descendant. We match
|
||||||
|
# by filepath prefix instead of folder_id because that catches photos
|
||||||
|
# in nested subfolders without a recursive folder walk.
|
||||||
|
photos_result = await db.execute(
|
||||||
|
select(Photo).where(
|
||||||
|
(Photo.filepath == folder_path)
|
||||||
|
| (Photo.filepath.like(descendant_prefix + '%'))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
photos = photos_result.scalars().all()
|
||||||
|
|
||||||
|
if mode == 'discard':
|
||||||
|
from datetime import datetime
|
||||||
|
now = datetime.utcnow()
|
||||||
|
for p in photos:
|
||||||
|
p.is_discarded = True
|
||||||
|
p.discarded_at = now
|
||||||
|
await db.commit()
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"mode": "discard",
|
||||||
|
"discarded": len(photos),
|
||||||
|
}
|
||||||
|
|
||||||
|
# mode == 'permanent'
|
||||||
|
file_errors = 0
|
||||||
|
for p in photos:
|
||||||
|
try:
|
||||||
|
if p.filepath and os.path.exists(p.filepath):
|
||||||
|
os.unlink(p.filepath)
|
||||||
|
except OSError as e:
|
||||||
|
file_errors += 1
|
||||||
|
logger.error(f"Failed to unlink {p.filepath}: {e}")
|
||||||
|
await db.delete(p)
|
||||||
|
|
||||||
|
# Delete this folder + every descendant Folder row.
|
||||||
|
await db.execute(
|
||||||
|
sql_delete(Folder).where(
|
||||||
|
(Folder.id == folder.id)
|
||||||
|
| (Folder.path.like(descendant_prefix + '%'))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if os.path.isdir(folder_path):
|
||||||
|
shutil.rmtree(folder_path)
|
||||||
|
except OSError as e:
|
||||||
|
logger.error(f"Failed to rmtree {folder_path}: {e}")
|
||||||
|
# Don't raise — DB rows are already gone, leaving an orphan
|
||||||
|
# directory is the lesser evil.
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"mode": "permanent",
|
||||||
|
"deleted_photos": len(photos),
|
||||||
|
"file_errors": file_errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{folder_id}/scan")
|
@router.post("/{folder_id}/scan")
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
<html lang="en" class="dark">
|
<html lang="en" class="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
|
<link rel="apple-touch-icon" href="/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Mulita - Photo Management</title>
|
<meta name="theme-color" content="#0f0f0f" />
|
||||||
|
<title>Mulimago</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
BIN
frontend/public/favicon.png
Normal file
BIN
frontend/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 821 KiB |
164
frontend/src/components/dialogs/DeleteFolderDialog.tsx
Normal file
164
frontend/src/components/dialogs/DeleteFolderDialog.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { Trash2, Archive } from 'lucide-react'
|
||||||
|
|
||||||
|
interface DeleteFolderDialogProps {
|
||||||
|
isOpen: boolean
|
||||||
|
folderName: string
|
||||||
|
/** Number of photos under this folder, including descendants. Surfaced
|
||||||
|
* in the dialog copy so the user understands the blast radius. */
|
||||||
|
photoCount?: number
|
||||||
|
onClose: () => void
|
||||||
|
/** Called with the chosen mode when the user confirms. */
|
||||||
|
onConfirm: (mode: 'discard' | 'permanent') => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-mode folder delete dialog:
|
||||||
|
*
|
||||||
|
* - Move to discard pile (default, soft, recoverable)
|
||||||
|
* - Permanently delete (destructive, irreversible)
|
||||||
|
*
|
||||||
|
* The user picks a mode via the radio cards then clicks Delete. Esc /
|
||||||
|
* backdrop click cancels.
|
||||||
|
*/
|
||||||
|
export function DeleteFolderDialog({
|
||||||
|
isOpen,
|
||||||
|
folderName,
|
||||||
|
photoCount,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: DeleteFolderDialogProps) {
|
||||||
|
const [mode, setMode] = useState<'discard' | 'permanent'>('discard')
|
||||||
|
|
||||||
|
// Reset mode when re-opening so the safe option is always the default.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) setMode('discard')
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
const photoBlurb =
|
||||||
|
photoCount === undefined
|
||||||
|
? 'photos in this folder'
|
||||||
|
: photoCount === 0
|
||||||
|
? 'this empty folder'
|
||||||
|
: `${photoCount} photo${photoCount === 1 ? '' : 's'} in this folder`
|
||||||
|
|
||||||
|
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-[420px] rounded-lg border border-border bg-surface p-5 shadow-2xl">
|
||||||
|
<h2 className="mb-1 text-base font-semibold text-text">
|
||||||
|
Delete folder "{folderName}"?
|
||||||
|
</h2>
|
||||||
|
<p className="mb-4 text-sm text-text-muted">
|
||||||
|
What should happen to {photoBlurb}?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<ModeCard
|
||||||
|
icon={<Archive className="h-4 w-4" />}
|
||||||
|
title="Move photos to discard pile"
|
||||||
|
description="Photos can be restored later from Discarded. The folder and files stay on disk."
|
||||||
|
selected={mode === 'discard'}
|
||||||
|
onClick={() => setMode('discard')}
|
||||||
|
/>
|
||||||
|
<ModeCard
|
||||||
|
icon={<Trash2 className="h-4 w-4" />}
|
||||||
|
title="Permanently delete folder and photos"
|
||||||
|
description="Removes the folder, every photo inside it, and the directory from disk. This cannot be undone."
|
||||||
|
selected={mode === 'permanent'}
|
||||||
|
destructive
|
||||||
|
onClick={() => setMode('permanent')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 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"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onConfirm(mode)}
|
||||||
|
className={clsx(
|
||||||
|
'rounded px-3 py-1.5 text-sm font-medium text-white',
|
||||||
|
mode === 'permanent'
|
||||||
|
? 'bg-reject hover:bg-reject/80'
|
||||||
|
: 'bg-primary hover:bg-primary/80'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{mode === 'permanent' ? 'Delete forever' : 'Move to discard pile'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModeCard({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
selected,
|
||||||
|
destructive = false,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
selected: boolean
|
||||||
|
destructive?: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={clsx(
|
||||||
|
'flex w-full gap-3 rounded-lg border p-3 text-left transition-colors',
|
||||||
|
selected
|
||||||
|
? destructive
|
||||||
|
? 'border-reject/60 bg-reject/10'
|
||||||
|
: 'border-primary/60 bg-primary/10'
|
||||||
|
: 'border-border bg-surface-2 hover:bg-surface-offset'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'mt-0.5 flex-shrink-0',
|
||||||
|
selected ? (destructive ? 'text-reject' : 'text-primary') : 'text-text-muted'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'text-sm font-medium',
|
||||||
|
selected ? (destructive ? 'text-reject' : 'text-primary') : 'text-text'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-xs text-text-muted">{description}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -45,6 +45,14 @@ export function FilterBar() {
|
|||||||
const sortBy = useFilterStore((s) => s.sortBy)
|
const sortBy = useFilterStore((s) => s.sortBy)
|
||||||
const sortOrder = useFilterStore((s) => s.sortOrder)
|
const sortOrder = useFilterStore((s) => s.sortOrder)
|
||||||
const tagIds = useFilterStore((s) => s.tagIds)
|
const tagIds = useFilterStore((s) => s.tagIds)
|
||||||
|
const currentSection = useFilterStore((s) => s.currentSection)
|
||||||
|
|
||||||
|
// Only the Flag pill is hidden inside the Discarded section. Flag has
|
||||||
|
// exactly two values and the section locks one of them, so the pill
|
||||||
|
// would only ever toggle the section off — useless. Rating + Tags
|
||||||
|
// pills stay visible in their sections because the user can refine
|
||||||
|
// them further (ratingMin >= 3, restrict to specific tag ids).
|
||||||
|
const hideFlagPill = currentSection === 'discarded'
|
||||||
|
|
||||||
const setDateFrom = useFilterStore((s) => s.setDateFrom)
|
const setDateFrom = useFilterStore((s) => s.setDateFrom)
|
||||||
const setDateTo = useFilterStore((s) => s.setDateTo)
|
const setDateTo = useFilterStore((s) => s.setDateTo)
|
||||||
@@ -116,43 +124,12 @@ export function FilterBar() {
|
|||||||
const anyActive = hasActiveFilters(filterState)
|
const anyActive = hasActiveFilters(filterState)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 border-b border-border bg-surface px-3 py-1.5">
|
// Fixed bar height + py-0 so neither the active filter pills nor the
|
||||||
{/* Search — left of the pill cluster. Same id as before so the
|
// clear-all button can stretch the bar vertically. The fixed h-11
|
||||||
* global "/" focus shortcut still finds it. */}
|
// matches the h-7 pills + 8px symmetric vertical padding.
|
||||||
<div className="relative w-56 flex-shrink-0">
|
<div className="flex h-11 items-center gap-3 border-b border-border bg-surface px-3 py-0">
|
||||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted" />
|
{/* Pills — left side, scroll horizontally if they overflow. */}
|
||||||
<input
|
<div className="flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto">
|
||||||
id="topbar-search"
|
|
||||||
type="text"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
setSearchQuery('')
|
|
||||||
setStoreQ('')
|
|
||||||
e.currentTarget.blur()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="Search photos…"
|
|
||||||
className="w-full rounded-full border border-border bg-surface-2 py-1 pl-8 pr-7 text-xs text-text placeholder-text-muted focus:border-primary focus:outline-none"
|
|
||||||
/>
|
|
||||||
{searchQuery && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setSearchQuery('')
|
|
||||||
setStoreQ('')
|
|
||||||
}}
|
|
||||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded-full p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
|
|
||||||
title="Clear search (Esc)"
|
|
||||||
aria-label="Clear search"
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pills — centered, scroll horizontally if they overflow. */}
|
|
||||||
<div className="flex flex-1 items-center justify-center gap-1.5 overflow-x-auto">
|
|
||||||
{/* Date */}
|
{/* Date */}
|
||||||
<FilterPill
|
<FilterPill
|
||||||
label="Date"
|
label="Date"
|
||||||
@@ -279,38 +256,41 @@ export function FilterBar() {
|
|||||||
</div>
|
</div>
|
||||||
</FilterPill>
|
</FilterPill>
|
||||||
|
|
||||||
{/* Flag — discarded toggle */}
|
{/* Flag — hidden in the Discarded section, where the flag is
|
||||||
<FilterPill
|
* pinned to "discarded" by the section preset. */}
|
||||||
label="Flag"
|
{!hideFlagPill && (
|
||||||
value={flagValue}
|
<FilterPill
|
||||||
isActive={flagActive}
|
label="Flag"
|
||||||
onClear={() => setFlag('any')}
|
value={flagValue}
|
||||||
>
|
isActive={flagActive}
|
||||||
<div className="flex flex-col gap-1">
|
onClear={() => setFlag('any')}
|
||||||
<button
|
>
|
||||||
onClick={() => setFlag('any')}
|
<div className="flex flex-col gap-1">
|
||||||
className={clsx(
|
<button
|
||||||
'rounded px-2 py-1 text-left text-xs transition-colors',
|
onClick={() => setFlag('any')}
|
||||||
flag === 'any'
|
className={clsx(
|
||||||
? 'bg-primary text-white'
|
'rounded px-2 py-1 text-left text-xs transition-colors',
|
||||||
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
|
flag === 'any'
|
||||||
)}
|
? 'bg-primary text-white'
|
||||||
>
|
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
|
||||||
Any
|
)}
|
||||||
</button>
|
>
|
||||||
<button
|
Any
|
||||||
onClick={() => setFlag('discarded')}
|
</button>
|
||||||
className={clsx(
|
<button
|
||||||
'rounded px-2 py-1 text-left text-xs transition-colors',
|
onClick={() => setFlag('discarded')}
|
||||||
flag === 'discarded'
|
className={clsx(
|
||||||
? 'bg-primary text-white'
|
'rounded px-2 py-1 text-left text-xs transition-colors',
|
||||||
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
|
flag === 'discarded'
|
||||||
)}
|
? 'bg-primary text-white'
|
||||||
>
|
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
|
||||||
Discarded
|
)}
|
||||||
</button>
|
>
|
||||||
</div>
|
Discarded
|
||||||
</FilterPill>
|
</button>
|
||||||
|
</div>
|
||||||
|
</FilterPill>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
{allTags.length > 0 && (
|
{allTags.length > 0 && (
|
||||||
@@ -375,18 +355,55 @@ export function FilterBar() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</FilterPill>
|
</FilterPill>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Clear-all — pinned right of the pill cluster. */}
|
{/* Clear-all — borderless text affordance pinned next to the pill
|
||||||
|
* cluster on the right. Lives inside the pills container so it
|
||||||
|
* shares the same flex group and gap and reads as "another
|
||||||
|
* pill". Only renders when any filter is active. */}
|
||||||
{anyActive && (
|
{anyActive && (
|
||||||
<button
|
<button
|
||||||
onClick={clearAll}
|
onClick={clearAll}
|
||||||
className="flex-shrink-0 whitespace-nowrap rounded-full border border-border px-2.5 py-1 text-xs text-text-muted hover:bg-surface-2 hover:text-text"
|
className="ml-1 flex h-7 flex-shrink-0 items-center whitespace-nowrap px-1 text-xs text-text-muted underline-offset-2 hover:text-text hover:underline"
|
||||||
title="Clear all filters in this section"
|
title="Clear all filters in this section"
|
||||||
>
|
>
|
||||||
Clear all
|
Clear all
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search — pinned to the right edge of the bar. Same id as before
|
||||||
|
* so the global "/" focus shortcut still finds it. */}
|
||||||
|
<div className="relative w-56 flex-shrink-0">
|
||||||
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted" />
|
||||||
|
<input
|
||||||
|
id="topbar-search"
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setSearchQuery('')
|
||||||
|
setStoreQ('')
|
||||||
|
e.currentTarget.blur()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Search photos…"
|
||||||
|
className="w-full rounded-full border border-border bg-surface-2 py-1 pl-8 pr-7 text-xs text-text placeholder-text-muted focus:border-primary focus:outline-none"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSearchQuery('')
|
||||||
|
setStoreQ('')
|
||||||
|
}}
|
||||||
|
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded-full p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
|
||||||
|
title="Clear search (Esc)"
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,7 +100,10 @@ export function FilterPill({
|
|||||||
// canonical place to read/edit the filter value.
|
// canonical place to read/edit the filter value.
|
||||||
title={isActive && value ? `${label}: ${value}` : label}
|
title={isActive && value ? `${label}: ${value}` : label}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs transition-colors',
|
// Fixed height + py-0 so neither the X clear icon nor the
|
||||||
|
// chevron can stretch the pill vertically when the active
|
||||||
|
// state swaps them in.
|
||||||
|
'flex h-7 items-center gap-1 rounded-full border px-2.5 py-0 text-xs transition-colors',
|
||||||
isActive
|
isActive
|
||||||
? 'border-primary/40 bg-primary/15 text-primary'
|
? 'border-primary/40 bg-primary/15 text-primary'
|
||||||
: 'border-border bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
|
: 'border-border bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
|
||||||
@@ -122,14 +125,18 @@ export function FilterPill({
|
|||||||
onClear()
|
onClear()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="ml-1 inline-flex h-5 w-5 cursor-pointer items-center justify-center rounded-full hover:bg-primary/30"
|
// Same h-4 w-4 as the chevron slot below so swapping the
|
||||||
|
// two doesn't change the pill's footprint.
|
||||||
|
className="ml-0.5 inline-flex h-4 w-4 cursor-pointer items-center justify-center rounded-full hover:bg-primary/30"
|
||||||
title={`Clear ${label}`}
|
title={`Clear ${label}`}
|
||||||
aria-label={`Clear ${label}`}
|
aria-label={`Clear ${label}`}
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<ChevronDown className="h-3 w-3 opacity-60" />
|
<span className="inline-flex h-4 w-4 items-center justify-center">
|
||||||
|
<ChevronDown className="h-3 w-3 opacity-60" />
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,37 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { X, Folder, AlertCircle } from 'lucide-react'
|
import { X, Folder, AlertCircle } from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { sourceFolders, heaps as heapsApi, type Heap } from '../../services/api'
|
import {
|
||||||
|
heaps as heapsApi,
|
||||||
|
type Heap,
|
||||||
|
type FolderTreeNode,
|
||||||
|
} from '../../services/api'
|
||||||
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
||||||
|
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
|
||||||
import { toast } from '../ToastContainer'
|
import { toast } from '../ToastContainer'
|
||||||
|
|
||||||
|
interface FlatFolder {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
depth: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Walk the folder tree depth-first into a flat list with depth info so
|
||||||
|
* the picker can render every node — including nested subfolders — as
|
||||||
|
* one indented option. */
|
||||||
|
function flattenTree(nodes: FolderTreeNode[], depth = 0): FlatFolder[] {
|
||||||
|
const out: FlatFolder[] = []
|
||||||
|
for (const n of nodes) {
|
||||||
|
out.push({ id: n.id, name: n.name, path: n.path, depth })
|
||||||
|
if (n.children.length > 0) {
|
||||||
|
out.push(...flattenTree(n.children, depth + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
interface HeapConvertDialogProps {
|
interface HeapConvertDialogProps {
|
||||||
heap: Heap | null
|
heap: Heap | null
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -23,12 +49,10 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) {
|
|||||||
const [deleteHeap, setDeleteHeap] = useState(false)
|
const [deleteHeap, setDeleteHeap] = useState(false)
|
||||||
const [subfolderName, setSubfolderName] = useState('')
|
const [subfolderName, setSubfolderName] = useState('')
|
||||||
|
|
||||||
const { data: foldersData } = useQuery({
|
// Use the recursive folder tree, not the flat source-root list, so the
|
||||||
queryKey: ['folders'],
|
// user can pick a sub-folder at any depth as the target.
|
||||||
queryFn: sourceFolders.list,
|
const { data: tree = [] } = useFolderTreeQuery()
|
||||||
enabled: !!heap,
|
const folders = useMemo<FlatFolder[]>(() => flattenTree(tree), [tree])
|
||||||
})
|
|
||||||
const folders = foldersData?.folders ?? []
|
|
||||||
|
|
||||||
// Default to the first folder when the dialog opens or folders load.
|
// Default to the first folder when the dialog opens or folders load.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -77,7 +101,7 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) {
|
|||||||
|
|
||||||
if (!heap) return null
|
if (!heap) return null
|
||||||
|
|
||||||
const targetFolder = folders.find((f: any) => f.id === targetId)
|
const targetFolder = folders.find((f) => f.id === targetId)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
@@ -110,9 +134,11 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) {
|
|||||||
onChange={(e) => setTargetId(e.target.value)}
|
onChange={(e) => setTargetId(e.target.value)}
|
||||||
className="w-full rounded border border-border bg-bg px-2 py-1.5 text-sm text-text focus:border-primary focus:outline-none"
|
className="w-full rounded border border-border bg-bg px-2 py-1.5 text-sm text-text focus:border-primary focus:outline-none"
|
||||||
>
|
>
|
||||||
{folders.map((f: any) => (
|
{folders.map((f) => (
|
||||||
<option key={f.id} value={f.id}>
|
<option key={f.id} value={f.id}>
|
||||||
{f.name}
|
{/* Two non-breaking spaces per depth so nested
|
||||||
|
* subfolders read as a tree in the native dropdown. */}
|
||||||
|
{'\u00A0\u00A0'.repeat(f.depth) + f.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -338,17 +338,13 @@ export function HeapsPanel() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Right-aligned cluster. Active indicator + count are
|
{/* Right cluster. Count is the rightmost element in the
|
||||||
* always visible; set-active and kebab appear on hover
|
* resting state — set-active and kebab use display:none
|
||||||
* to the RIGHT of the count, displacing it slightly so
|
* (not invisible) so they reserve no width until hover,
|
||||||
* the count column lines up with the rest of the
|
* keeping the count column aligned with the rest of the
|
||||||
* sidebar in the resting state. */}
|
* sidebar. The active heap is signaled by font-semibold
|
||||||
{isActive && (
|
* on the name above; the standalone Target indicator
|
||||||
<Target
|
* was making heap counts sit left of the others. */}
|
||||||
className="h-3 w-3 flex-shrink-0 text-primary"
|
|
||||||
aria-label="Active heap (T target)"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{heap.photo_count > 0 ? (
|
{heap.photo_count > 0 ? (
|
||||||
<span className="flex h-5 min-w-[24px] flex-shrink-0 items-center justify-center rounded bg-surface-offset px-1.5 text-xs tabular-nums text-text-muted">
|
<span className="flex h-5 min-w-[24px] flex-shrink-0 items-center justify-center rounded bg-surface-offset px-1.5 text-xs tabular-nums text-text-muted">
|
||||||
{heap.photo_count}
|
{heap.photo_count}
|
||||||
@@ -362,7 +358,7 @@ export function HeapsPanel() {
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setActiveMutation.mutate(heap.id)
|
setActiveMutation.mutate(heap.id)
|
||||||
}}
|
}}
|
||||||
className="invisible flex-shrink-0 rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text group-hover:visible"
|
className="hidden flex-shrink-0 rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text group-hover:block"
|
||||||
title="Set as active heap (T target)"
|
title="Set as active heap (T target)"
|
||||||
aria-label="Set as active heap"
|
aria-label="Set as active heap"
|
||||||
>
|
>
|
||||||
@@ -372,16 +368,18 @@ export function HeapsPanel() {
|
|||||||
|
|
||||||
{/* Kebab menu — collects rename / duplicate / convert /
|
{/* Kebab menu — collects rename / duplicate / convert /
|
||||||
* delete so the row stays compact. */}
|
* delete so the row stays compact. */}
|
||||||
<div className="relative flex-shrink-0">
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'relative flex-shrink-0',
|
||||||
|
isMenuOpen ? 'block' : 'hidden group-hover:block'
|
||||||
|
)}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setOpenMenuId(isMenuOpen ? null : heap.id)
|
setOpenMenuId(isMenuOpen ? null : heap.id)
|
||||||
}}
|
}}
|
||||||
className={clsx(
|
className="rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
|
||||||
'rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text',
|
|
||||||
isMenuOpen ? 'visible' : 'invisible group-hover:visible'
|
|
||||||
)}
|
|
||||||
title="More actions"
|
title="More actions"
|
||||||
aria-label="More heap actions"
|
aria-label="More heap actions"
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Folder,
|
Folder,
|
||||||
|
FolderPlus,
|
||||||
Image,
|
Image,
|
||||||
Star,
|
Star,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -11,6 +12,8 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
Tag as TagIcon,
|
Tag as TagIcon,
|
||||||
Layers2,
|
Layers2,
|
||||||
|
MoreHorizontal,
|
||||||
|
Pencil,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { sourceFolders, library, photos as photosApi, type FolderTreeNode } from '../../services/api'
|
import { sourceFolders, library, photos as photosApi, type FolderTreeNode } from '../../services/api'
|
||||||
@@ -27,6 +30,7 @@ import {
|
|||||||
} from '../../hooks/useLibraryStatsQuery'
|
} from '../../hooks/useLibraryStatsQuery'
|
||||||
import { registerUndoable } from '../../store/undoStore'
|
import { registerUndoable } from '../../store/undoStore'
|
||||||
import type { Photo } from '../../types/photo'
|
import type { Photo } from '../../types/photo'
|
||||||
|
import { DeleteFolderDialog } from '../dialogs/DeleteFolderDialog'
|
||||||
|
|
||||||
interface TreeItem {
|
interface TreeItem {
|
||||||
id: string
|
id: string
|
||||||
@@ -52,6 +56,40 @@ export function LeftSidebar() {
|
|||||||
const { data: stats } = useLibraryStatsQuery()
|
const { data: stats } = useLibraryStatsQuery()
|
||||||
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
|
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Per-folder kebab menu open state. Stores the tree-item id ("folder-..."
|
||||||
|
// or "folders" for the section header). Outside-click + Escape close.
|
||||||
|
const [openMenuId, setOpenMenuId] = useState<string | null>(null)
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!openMenuId) return
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
|
setOpenMenuId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setOpenMenuId(null)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDown)
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDown)
|
||||||
|
document.removeEventListener('keydown', onKey)
|
||||||
|
}
|
||||||
|
}, [openMenuId])
|
||||||
|
|
||||||
|
// "Create new folder under {parent}" inline state. parentId is the
|
||||||
|
// Folder.id (no "folder-" prefix).
|
||||||
|
const [creatingUnder, setCreatingUnder] = useState<string | null>(null)
|
||||||
|
const [createDraft, setCreateDraft] = useState('')
|
||||||
|
|
||||||
|
// Folder being deleted, drives the DeleteFolderDialog mounted below.
|
||||||
|
const [deletingFolder, setDeletingFolder] = useState<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
photoCount?: number
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
// Bulk discard mutation for the drag-onto-Discarded interaction.
|
// Bulk discard mutation for the drag-onto-Discarded interaction.
|
||||||
const discardDropMutation = useMutation({
|
const discardDropMutation = useMutation({
|
||||||
mutationFn: (photoIds: string[]) => photosApi.bulkDiscard(photoIds),
|
mutationFn: (photoIds: string[]) => photosApi.bulkDiscard(photoIds),
|
||||||
@@ -217,11 +255,55 @@ export function LeftSidebar() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
|
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
},
|
},
|
||||||
onError: (e: any) =>
|
onError: (e: any) =>
|
||||||
toast.error('Rename failed', e?.response?.data?.detail || e.message || 'Unknown error'),
|
toast.error('Rename failed', e?.response?.data?.detail || e.message || 'Unknown error'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const createFolderMutation = useMutation({
|
||||||
|
mutationFn: ({ parentId, name }: { parentId: string; name: string }) =>
|
||||||
|
sourceFolders.create(parentId, name),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast.success('Folder created', data.name)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
|
||||||
|
setCreatingUnder(null)
|
||||||
|
setCreateDraft('')
|
||||||
|
},
|
||||||
|
onError: (e: any) =>
|
||||||
|
toast.error('Create failed', e?.response?.data?.detail || e.message || 'Unknown error'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteFolderMutation = useMutation({
|
||||||
|
mutationFn: ({ id, mode }: { id: string; mode: 'discard' | 'permanent' }) =>
|
||||||
|
sourceFolders.delete(id, mode),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data.mode === 'discard') {
|
||||||
|
toast.success(
|
||||||
|
'Folder photos discarded',
|
||||||
|
`${data.discarded ?? 0} moved to discard pile`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
toast.success(
|
||||||
|
'Folder deleted',
|
||||||
|
`${data.deleted_photos ?? 0} photos removed from disk`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||||
|
// If we were viewing the deleted folder, snap back to all-photos.
|
||||||
|
if (deletingFolder && currentSection === `folder-${deletingFolder.id}`) {
|
||||||
|
navigateToSection('all-photos', {})
|
||||||
|
}
|
||||||
|
setDeletingFolder(null)
|
||||||
|
},
|
||||||
|
onError: (e: any) =>
|
||||||
|
toast.error('Delete failed', e?.response?.data?.detail || e.message || 'Unknown error'),
|
||||||
|
})
|
||||||
|
|
||||||
// Mutation for scanning all folders
|
// Mutation for scanning all folders
|
||||||
const scanLibraryMutation = useMutation({
|
const scanLibraryMutation = useMutation({
|
||||||
mutationFn: library.scan,
|
mutationFn: library.scan,
|
||||||
@@ -455,8 +537,131 @@ export function LeftSidebar() {
|
|||||||
<span className="h-5 min-w-[24px] flex-shrink-0" aria-hidden="true" />
|
<span className="h-5 min-w-[24px] flex-shrink-0" aria-hidden="true" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Folder kebab menu — only on folder rows. Hidden (display:none)
|
||||||
|
* until hover so it reserves NO width in the resting state and
|
||||||
|
* the count column stays aligned across folder + non-folder
|
||||||
|
* rows. On hover it appears to the right, pushing the count
|
||||||
|
* left to make room. */}
|
||||||
|
{item.id.startsWith('folder-') &&
|
||||||
|
(() => {
|
||||||
|
const folderId = item.id.slice('folder-'.length)
|
||||||
|
const isMenuOpen = openMenuId === item.id
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'relative flex-shrink-0',
|
||||||
|
isMenuOpen ? 'block' : 'hidden group-hover:block'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setOpenMenuId(isMenuOpen ? null : item.id)
|
||||||
|
}}
|
||||||
|
className="rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
|
||||||
|
title="More actions"
|
||||||
|
aria-label="More folder actions"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={isMenuOpen}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isMenuOpen && (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
role="menu"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="absolute right-0 top-full z-30 mt-1 min-w-[180px] overflow-hidden rounded-lg border border-border bg-surface py-1 text-sm shadow-xl"
|
||||||
|
>
|
||||||
|
<FolderMenuItem
|
||||||
|
icon={<FolderPlus className="h-3.5 w-3.5" />}
|
||||||
|
label="New sub-folder"
|
||||||
|
onClick={() => {
|
||||||
|
setOpenMenuId(null)
|
||||||
|
setCreatingUnder(folderId)
|
||||||
|
setCreateDraft('')
|
||||||
|
// Make sure the parent is expanded so the new
|
||||||
|
// input is visible.
|
||||||
|
if (!expandedItems.has(item.id)) {
|
||||||
|
toggleExpanded(item.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FolderMenuItem
|
||||||
|
icon={<Pencil className="h-3.5 w-3.5" />}
|
||||||
|
label="Rename"
|
||||||
|
onClick={() => {
|
||||||
|
setOpenMenuId(null)
|
||||||
|
setRenamingId(item.id)
|
||||||
|
setRenameDraft(item.label)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="my-1 h-px bg-border" />
|
||||||
|
<FolderMenuItem
|
||||||
|
icon={<Trash2 className="h-3.5 w-3.5" />}
|
||||||
|
label="Delete folder…"
|
||||||
|
destructive
|
||||||
|
onClick={() => {
|
||||||
|
setOpenMenuId(null)
|
||||||
|
setDeletingFolder({
|
||||||
|
id: folderId,
|
||||||
|
name: item.label,
|
||||||
|
photoCount: item.count,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Inline "create new sub-folder" input. Renders just below the
|
||||||
|
* parent row when its create state is active. */}
|
||||||
|
{item.id.startsWith('folder-') &&
|
||||||
|
creatingUnder === item.id.slice('folder-'.length) && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 px-2 py-1"
|
||||||
|
style={{ paddingLeft: `${8 + (depth + 1) * 16 + 4}px` }}
|
||||||
|
>
|
||||||
|
<FolderPlus className="h-3 w-3 flex-shrink-0 text-text-muted" />
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
value={createDraft}
|
||||||
|
placeholder="New folder name"
|
||||||
|
onChange={(e) => setCreateDraft(e.target.value)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
const name = createDraft.trim()
|
||||||
|
if (name) {
|
||||||
|
createFolderMutation.mutate({
|
||||||
|
parentId: item.id.slice('folder-'.length),
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setCreatingUnder(null)
|
||||||
|
setCreateDraft('')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
// Don't auto-commit on blur — empty/escaped renames
|
||||||
|
// close the input but don't fire the request.
|
||||||
|
if (!createFolderMutation.isPending) {
|
||||||
|
setCreatingUnder(null)
|
||||||
|
setCreateDraft('')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex-1 rounded border border-border bg-bg px-1 py-0 text-[13px] text-text focus:border-primary focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Render Children */}
|
{/* Render Children */}
|
||||||
{hasChildren && isExpanded && (
|
{hasChildren && isExpanded && (
|
||||||
<div>
|
<div>
|
||||||
@@ -488,6 +693,46 @@ export function LeftSidebar() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<DeleteFolderDialog
|
||||||
|
isOpen={!!deletingFolder}
|
||||||
|
folderName={deletingFolder?.name ?? ''}
|
||||||
|
photoCount={deletingFolder?.photoCount}
|
||||||
|
onClose={() => setDeletingFolder(null)}
|
||||||
|
onConfirm={(mode) => {
|
||||||
|
if (deletingFolder) {
|
||||||
|
deleteFolderMutation.mutate({ id: deletingFolder.id, mode })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FolderMenuItem({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
destructive = false,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode
|
||||||
|
label: string
|
||||||
|
onClick: () => void
|
||||||
|
destructive?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
role="menuitem"
|
||||||
|
onClick={onClick}
|
||||||
|
className={clsx(
|
||||||
|
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors',
|
||||||
|
destructive
|
||||||
|
? 'text-reject hover:bg-reject/10'
|
||||||
|
: 'text-text hover:bg-surface-2'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-text-muted">{icon}</span>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -150,8 +150,8 @@ export function RightSidebar() {
|
|||||||
const id = activePhotoId ?? selectedPhotos[0]
|
const id = activePhotoId ?? selectedPhotos[0]
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-surface">
|
<div className="flex h-full flex-col bg-surface">
|
||||||
<div className="flex h-12 flex-shrink-0 items-center justify-between border-b border-border px-4">
|
<div className="flex h-11 flex-shrink-0 items-center justify-between border-b border-border px-4">
|
||||||
<h2 className="text-sm font-semibold text-text">Photo Details</h2>
|
<h2 className="text-sm font-semibold text-text">Metadata</h2>
|
||||||
<button
|
<button
|
||||||
onClick={clearSelection}
|
onClick={clearSelection}
|
||||||
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
|
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ export function TopBar() {
|
|||||||
<header className="flex h-12 items-center justify-between border-b border-border bg-surface px-4">
|
<header className="flex h-12 items-center justify-between border-b border-border bg-surface px-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<img src={muliLogo} alt="Mulita" className="h-7 w-7 object-contain" />
|
<img src={muliLogo} alt="Mulimago" className="h-7 w-7 object-contain" />
|
||||||
<h1 className="text-lg font-semibold text-text">Mulita</h1>
|
<h1 className="text-lg font-semibold text-text">Mulimago</h1>
|
||||||
</div>
|
</div>
|
||||||
{activeHeap && (
|
{activeHeap && (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useHotkeys } from 'react-hotkeys-hook'
|
import { useHotkeys } from 'react-hotkeys-hook'
|
||||||
import { X, Info } from 'lucide-react'
|
import { X, Info } from 'lucide-react'
|
||||||
import { usePhotoStore } from '../../store/photoStore'
|
import { usePhotoStore } from '../../store/photoStore'
|
||||||
@@ -13,6 +13,7 @@ export function PreviewView() {
|
|||||||
const activePhotoId = usePhotoStore((s) => s.activePhotoId)
|
const activePhotoId = usePhotoStore((s) => s.activePhotoId)
|
||||||
const setActivePhoto = usePhotoStore((s) => s.setActivePhoto)
|
const setActivePhoto = usePhotoStore((s) => s.setActivePhoto)
|
||||||
const closePreview = usePhotoStore((s) => s.closePreview)
|
const closePreview = usePhotoStore((s) => s.closePreview)
|
||||||
|
const visiblePhotoIds = usePhotoStore((s) => s.visiblePhotoIds)
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const previouslyFocusedRef = useRef<HTMLElement | null>(null)
|
const previouslyFocusedRef = useRef<HTMLElement | null>(null)
|
||||||
@@ -20,7 +21,23 @@ export function PreviewView() {
|
|||||||
|
|
||||||
// Same hook Timeline uses, so we share one cache entry rather than looking
|
// Same hook Timeline uses, so we share one cache entry rather than looking
|
||||||
// it up by key (which broke when the key gained the filter params).
|
// it up by key (which broke when the key gained the filter params).
|
||||||
const { data: photos = [] } = usePhotosQuery()
|
const { data: rawPhotos = [] } = usePhotosQuery()
|
||||||
|
|
||||||
|
// Walk the timeline's visible-order sequence (published by Timeline
|
||||||
|
// into the photo store), which respects tag-grouping and any other
|
||||||
|
// grid-layout rearrangement. Falls back to the raw photos list when
|
||||||
|
// the sequence isn't populated yet — relevant on a fresh page load
|
||||||
|
// where the user opened preview before the timeline mounted.
|
||||||
|
const photos: Photo[] = useMemo(() => {
|
||||||
|
if (visiblePhotoIds.length === 0) return rawPhotos
|
||||||
|
const byId = new Map(rawPhotos.map((p) => [p.id, p]))
|
||||||
|
const out: Photo[] = []
|
||||||
|
for (const id of visiblePhotoIds) {
|
||||||
|
const p = byId.get(id)
|
||||||
|
if (p) out.push(p)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}, [visiblePhotoIds, rawPhotos])
|
||||||
|
|
||||||
const currentIndex = activePhotoId
|
const currentIndex = activePhotoId
|
||||||
? photos.findIndex((p) => p.id === activePhotoId)
|
? photos.findIndex((p) => p.id === activePhotoId)
|
||||||
|
|||||||
@@ -173,6 +173,9 @@ export function Timeline() {
|
|||||||
clearSelection,
|
clearSelection,
|
||||||
openPreview,
|
openPreview,
|
||||||
} = usePhotoStore()
|
} = usePhotoStore()
|
||||||
|
// Pulled via a focused selector so the publisher subscription doesn't
|
||||||
|
// re-render Timeline on every unrelated photo store change.
|
||||||
|
const setVisiblePhotoIds = usePhotoStore((s) => s.setVisiblePhotoIds)
|
||||||
|
|
||||||
const sortBy = useFilterStore((s) => s.sortBy)
|
const sortBy = useFilterStore((s) => s.sortBy)
|
||||||
const groupBy = useFilterStore((s) => s.groupBy)
|
const groupBy = useFilterStore((s) => s.groupBy)
|
||||||
@@ -294,6 +297,21 @@ export function Timeline() {
|
|||||||
[items]
|
[items]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Publish the flat visible-order id sequence to the photo store so
|
||||||
|
// PreviewView arrow nav (and the filmstrip) walks the same order the
|
||||||
|
// user sees in the grid. Includes duplicates from tag grouping —
|
||||||
|
// landing on the same photo's "second" appearance in the next tag
|
||||||
|
// bucket is the right behavior in tag mode.
|
||||||
|
useEffect(() => {
|
||||||
|
const ids: string[] = []
|
||||||
|
for (const row of photoRows) {
|
||||||
|
for (const cell of row.cells) {
|
||||||
|
ids.push(cell.photo.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setVisiblePhotoIds(ids)
|
||||||
|
}, [photoRows, setVisiblePhotoIds])
|
||||||
|
|
||||||
// Locate the active photo in the visual grid. Returns the FIRST
|
// Locate the active photo in the visual grid. Returns the FIRST
|
||||||
// (rowIndex, colIndex) where its id appears, since a tag-grouped view
|
// (rowIndex, colIndex) where its id appears, since a tag-grouped view
|
||||||
// can repeat a photo across groups. Returns null when there's no
|
// can repeat a photo across groups. Returns null when there's no
|
||||||
@@ -415,7 +433,7 @@ export function Timeline() {
|
|||||||
if (photos.length === 0) {
|
if (photos.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="text-text-muted">No photos found. Add a source folder to get started.</div>
|
<div className="text-text-muted">(╯°□°)╯︵ ┻━┻</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -438,18 +456,39 @@ export function Timeline() {
|
|||||||
className="h-full overflow-auto bg-bg"
|
className="h-full overflow-auto bg-bg"
|
||||||
style={{ padding: `${PADDING}px` }}
|
style={{ padding: `${PADDING}px` }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: `${virtualizer.getTotalSize()}px`,
|
height: `${virtualizer.getTotalSize()}px`,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||||
const item = items[virtualItem.index]
|
const item = items[virtualItem.index]
|
||||||
if (!item) return null
|
if (!item) return null
|
||||||
|
|
||||||
if (item.type === 'header') {
|
if (item.type === 'header') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={virtualItem.key}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: `${virtualItem.size}px`,
|
||||||
|
transform: `translateY(${virtualItem.start}px)`,
|
||||||
|
}}
|
||||||
|
className="flex items-end pb-1"
|
||||||
|
>
|
||||||
|
<h3 className="text-sm font-semibold uppercase tracking-wide text-text-muted">
|
||||||
|
{item.label}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// row
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={virtualItem.key}
|
key={virtualItem.key}
|
||||||
@@ -461,53 +500,32 @@ export function Timeline() {
|
|||||||
height: `${virtualItem.size}px`,
|
height: `${virtualItem.size}px`,
|
||||||
transform: `translateY(${virtualItem.start}px)`,
|
transform: `translateY(${virtualItem.start}px)`,
|
||||||
}}
|
}}
|
||||||
className="flex items-end pb-1"
|
|
||||||
>
|
>
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-text-muted">
|
<div className="flex" style={{ gap: `${GAP}px` }}>
|
||||||
{item.label}
|
{item.cells.map(({ photo, globalIndex }) => (
|
||||||
</h3>
|
<PhotoThumbnail
|
||||||
|
key={photo.id}
|
||||||
|
photo={photo}
|
||||||
|
size={THUMBNAIL_SIZE}
|
||||||
|
isSelected={selectedPhotos.includes(photo.id)}
|
||||||
|
isInActiveHeap={activeHeapMembers.has(photo.id)}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.shiftKey && lastSelectedIndex !== null) {
|
||||||
|
selectRange(globalIndex)
|
||||||
|
} else if (e.ctrlKey || e.metaKey) {
|
||||||
|
togglePhotoSelection(photo.id, globalIndex)
|
||||||
|
} else {
|
||||||
|
selectPhoto(photo.id, globalIndex)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDoubleClick={() => openPreview(photo.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})}
|
||||||
|
</div>
|
||||||
// row
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={virtualItem.key}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
height: `${virtualItem.size}px`,
|
|
||||||
transform: `translateY(${virtualItem.start}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex" style={{ gap: `${GAP}px` }}>
|
|
||||||
{item.cells.map(({ photo, globalIndex }) => (
|
|
||||||
<PhotoThumbnail
|
|
||||||
key={photo.id}
|
|
||||||
photo={photo}
|
|
||||||
size={THUMBNAIL_SIZE}
|
|
||||||
isSelected={selectedPhotos.includes(photo.id)}
|
|
||||||
isInActiveHeap={activeHeapMembers.has(photo.id)}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (e.shiftKey && lastSelectedIndex !== null) {
|
|
||||||
selectRange(globalIndex)
|
|
||||||
} else if (e.ctrlKey || e.metaKey) {
|
|
||||||
togglePhotoSelection(photo.id, globalIndex)
|
|
||||||
} else {
|
|
||||||
selectPhoto(photo.id, globalIndex)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDoubleClick={() => openPreview(photo.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import axios from 'axios'
|
|
||||||
import { useFilterStore, filtersToParams } from '../store/filterStore'
|
import { useFilterStore, filtersToParams } from '../store/filterStore'
|
||||||
|
import api from '../services/api'
|
||||||
import type { Photo } from '../types/photo'
|
import type { Photo } from '../types/photo'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,8 +50,11 @@ export function usePhotosQuery() {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['photos', filterParams],
|
queryKey: ['photos', filterParams],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await axios.get<{ photos: Photo[]; total: number }>(
|
// Goes through the shared axios instance so it inherits the
|
||||||
'http://localhost:8001/api/v1/photos',
|
// relative /api/v1 baseURL — same-origin behind the nginx / vite
|
||||||
|
// proxy, no CORS dance required from another machine.
|
||||||
|
const response = await api.get<{ photos: Photo[]; total: number }>(
|
||||||
|
'/photos',
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
page: 1,
|
page: 1,
|
||||||
|
|||||||
@@ -42,12 +42,40 @@ export const sourceFolders = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Rename the display label only — the on-disk path is controlled by
|
/** Rename a folder. SourceRoot ids only update the display label;
|
||||||
* the docker mount and cannot be changed from the UI. */
|
* Folder ids actually move the directory on disk and update every
|
||||||
|
* descendant photo's filepath. */
|
||||||
rename: async (folderId: string, name: string) => {
|
rename: async (folderId: string, name: string) => {
|
||||||
const response = await api.patch(`/folders/${folderId}`, { name })
|
const response = await api.patch(`/folders/${folderId}`, { name })
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Create a new sub-folder under an existing Folder. parent_id MUST
|
||||||
|
* be a Folder row id (not a SourceRoot id). */
|
||||||
|
create: async (parentId: string, name: string) => {
|
||||||
|
const response = await api.post('/folders', {
|
||||||
|
name,
|
||||||
|
parent_id: parentId,
|
||||||
|
})
|
||||||
|
return response.data as { id: string; name: string; path: string; parent_id: string }
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Delete a folder. mode=discard moves all photos under it to the
|
||||||
|
* discard pile (recoverable) and leaves the folder + on-disk dir
|
||||||
|
* alone. mode=permanent unlinks files, removes folder rows, and
|
||||||
|
* rmtrees the directory — irreversible. */
|
||||||
|
delete: async (folderId: string, mode: 'discard' | 'permanent') => {
|
||||||
|
const response = await api.delete(`/folders/${folderId}`, {
|
||||||
|
params: { mode },
|
||||||
|
})
|
||||||
|
return response.data as {
|
||||||
|
status: string
|
||||||
|
mode: string
|
||||||
|
discarded?: number
|
||||||
|
deleted_photos?: number
|
||||||
|
file_errors?: number
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Photos API
|
// Photos API
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ interface PhotoStore {
|
|||||||
lastSelectedIndex: number | null
|
lastSelectedIndex: number | null
|
||||||
rangeStartIndex: number | null
|
rangeStartIndex: number | null
|
||||||
viewMode: ViewMode
|
viewMode: ViewMode
|
||||||
|
/** Flat sequence of photo ids in the order they currently appear in
|
||||||
|
* the timeline grid (including duplicates from tag-grouping). The
|
||||||
|
* preview view walks this sequence so arrow nav matches the order
|
||||||
|
* the user actually sees. Owned by the Timeline component, which
|
||||||
|
* rewrites it whenever its layout items change. */
|
||||||
|
visiblePhotoIds: string[]
|
||||||
|
|
||||||
setPhotos: (photos: Photo[]) => void
|
setPhotos: (photos: Photo[]) => void
|
||||||
selectPhoto: (id: string, index: number) => void
|
selectPhoto: (id: string, index: number) => void
|
||||||
@@ -19,6 +25,7 @@ interface PhotoStore {
|
|||||||
clearSelection: () => void
|
clearSelection: () => void
|
||||||
setActivePhoto: (id: string | null) => void
|
setActivePhoto: (id: string | null) => void
|
||||||
setViewMode: (mode: ViewMode) => void
|
setViewMode: (mode: ViewMode) => void
|
||||||
|
setVisiblePhotoIds: (ids: string[]) => void
|
||||||
openPreview: (id: string) => void
|
openPreview: (id: string) => void
|
||||||
closePreview: () => void
|
closePreview: () => void
|
||||||
}
|
}
|
||||||
@@ -30,6 +37,7 @@ export const usePhotoStore = create<PhotoStore>((set) => ({
|
|||||||
lastSelectedIndex: null,
|
lastSelectedIndex: null,
|
||||||
rangeStartIndex: null,
|
rangeStartIndex: null,
|
||||||
viewMode: 'grid',
|
viewMode: 'grid',
|
||||||
|
visiblePhotoIds: [],
|
||||||
|
|
||||||
setPhotos: (photos) => set({ photos }),
|
setPhotos: (photos) => set({ photos }),
|
||||||
|
|
||||||
@@ -73,6 +81,24 @@ export const usePhotoStore = create<PhotoStore>((set) => ({
|
|||||||
|
|
||||||
setViewMode: (mode) => set({ viewMode: mode }),
|
setViewMode: (mode) => set({ viewMode: mode }),
|
||||||
|
|
||||||
|
// No-op when the content is identical so callers can fire from an
|
||||||
|
// effect without risking a re-render loop.
|
||||||
|
setVisiblePhotoIds: (visiblePhotoIds) =>
|
||||||
|
set((s) => {
|
||||||
|
const prev = s.visiblePhotoIds
|
||||||
|
if (prev.length === visiblePhotoIds.length) {
|
||||||
|
let same = true
|
||||||
|
for (let i = 0; i < prev.length; i++) {
|
||||||
|
if (prev[i] !== visiblePhotoIds[i]) {
|
||||||
|
same = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (same) return s
|
||||||
|
}
|
||||||
|
return { visiblePhotoIds }
|
||||||
|
}),
|
||||||
|
|
||||||
openPreview: (id) => set({ viewMode: 'preview', activePhotoId: id }),
|
openPreview: (id) => set({ viewMode: 'preview', activePhotoId: id }),
|
||||||
|
|
||||||
closePreview: () => set({ viewMode: 'grid' }),
|
closePreview: () => set({ viewMode: 'grid' }),
|
||||||
|
|||||||
Reference in New Issue
Block a user