feat: safe deletion, album/asset delete, trash, README update
- volume-aware deletion: read-only volumes remove DB only, writable volumes soft-delete to trash with configurable grace period - trash page with restore, worker purge sweep (TRASH_RETENTION_DAYS) - album delete endpoint + sidebar trash icon - asset delete from timeline selection toolbar - all listing queries exclude trashed assets (deleted_at IS NULL) - timeline ordered by EXIF capture date, date-summary endpoint - README rewritten with features, setup, full env var table
This commit is contained in:
@@ -16,7 +16,7 @@ import { AlbumSidebar } from "@/components/album-sidebar"
|
||||
import { AdminSidebar } from "@/components/admin-sidebar"
|
||||
import { UploadDialog } from "@/components/upload-dialog"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { CameraIcon, LogOutIcon } from "lucide-react"
|
||||
import { CameraIcon, LogOutIcon, Trash2Icon } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import Link from "next/link"
|
||||
|
||||
@@ -53,13 +53,22 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
<AlbumSidebar />
|
||||
<AdminSidebar />
|
||||
</SidebarContent>
|
||||
<div className="flex items-center justify-between border-t px-4 py-2">
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{user?.username}
|
||||
</span>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={logout}>
|
||||
<LogOutIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<div className="flex flex-col border-t">
|
||||
<Link
|
||||
href="/trash"
|
||||
className="flex items-center gap-2 px-4 py-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Trash2Icon className="h-3.5 w-3.5" />
|
||||
Trash
|
||||
</Link>
|
||||
<div className="flex items-center justify-between px-4 py-2">
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{user?.username}
|
||||
</span>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={logout}>
|
||||
<LogOutIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Sidebar>
|
||||
<SidebarInset>
|
||||
|
||||
@@ -1,16 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { useTimeline, useDateSummary } from "@/hooks/use-timeline"
|
||||
import { groupByDate } from "@/lib/timeline"
|
||||
import { PhotoGrid } from "@/components/photo-grid"
|
||||
import { DateScrubber } from "@/components/date-scrubber"
|
||||
import api from "@/lib/api"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export default function TimelinePage() {
|
||||
const qc = useQueryClient()
|
||||
const { assets, isLoading, hasMore, loadMore, total } = useTimeline()
|
||||
const { data: dateSummary } = useDateSummary()
|
||||
const groups = useMemo(() => groupByDate(assets), [assets])
|
||||
|
||||
const handleDeleteAssets = async (ids: string[]) => {
|
||||
let deleted = 0
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await api.delete(`/assets/${id}`)
|
||||
deleted++
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
if (deleted > 0) {
|
||||
toast.success(`Deleted ${deleted} photo(s)`)
|
||||
qc.invalidateQueries({ queryKey: ["timeline"] })
|
||||
qc.invalidateQueries({ queryKey: ["date-summary"] })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -27,6 +46,7 @@ export default function TimelinePage() {
|
||||
isLoading={isLoading}
|
||||
hasMore={hasMore}
|
||||
onLoadMore={() => loadMore()}
|
||||
onDeleteAssets={handleDeleteAssets}
|
||||
/>
|
||||
<DateScrubber dates={dateSummary ?? []} />
|
||||
</div>
|
||||
|
||||
87
k-photos-frontend/app/(app)/trash/page.tsx
Normal file
87
k-photos-frontend/app/(app)/trash/page.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
|
||||
import api from "@/lib/api"
|
||||
import type { TimelineResponse, AssetResponse } from "@/lib/types"
|
||||
import { PhotoCard } from "@/components/photo-card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { toast } from "sonner"
|
||||
import { RotateCcwIcon } from "lucide-react"
|
||||
|
||||
export default function TrashPage() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["trash"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<TimelineResponse>("/assets/trash", {
|
||||
params: { limit: 100, offset: 0 },
|
||||
})
|
||||
return data
|
||||
},
|
||||
})
|
||||
|
||||
const restore = useMutation({
|
||||
mutationFn: async (assetId: string) => {
|
||||
await api.post(`/assets/${assetId}/restore`)
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["trash"] })
|
||||
qc.invalidateQueries({ queryKey: ["timeline"] })
|
||||
qc.invalidateQueries({ queryKey: ["date-summary"] })
|
||||
},
|
||||
})
|
||||
|
||||
const assets = data?.assets ?? []
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold">Trash</h1>
|
||||
{data && data.total > 0 && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{data.total} items
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<Spinner />
|
||||
) : assets.length === 0 ? (
|
||||
<p className="py-12 text-center text-muted-foreground">
|
||||
Trash is empty
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-1 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{assets.map((asset) => (
|
||||
<div key={asset.id} className="group relative">
|
||||
<div className="opacity-60">
|
||||
<PhotoCard asset={asset} />
|
||||
</div>
|
||||
<div className="absolute top-1.5 right-1.5 opacity-0 group-hover:opacity-100">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
className="h-7 w-7"
|
||||
disabled={restore.isPending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await restore.mutateAsync(asset.id)
|
||||
toast.success("Photo restored")
|
||||
} catch {
|
||||
toast.error("Failed to restore")
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RotateCcwIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -14,11 +14,12 @@ import {
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useAlbums } from "@/hooks/use-albums"
|
||||
import { ImageIcon, PlusIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { ImageIcon, PlusIcon, Trash2Icon } from "lucide-react"
|
||||
|
||||
export function AlbumSidebar() {
|
||||
const pathname = usePathname()
|
||||
const { albums, createAlbum } = useAlbums()
|
||||
const { albums, createAlbum, deleteAlbum } = useAlbums()
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [newTitle, setNewTitle] = useState("")
|
||||
|
||||
@@ -59,14 +60,31 @@ export function AlbumSidebar() {
|
||||
)}
|
||||
<SidebarMenu>
|
||||
{albums.map((album) => (
|
||||
<SidebarMenuItem key={album.id}>
|
||||
<SidebarMenuItem key={album.id} className="group/album">
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={pathname === `/albums/${album.id}`}
|
||||
>
|
||||
<Link href={`/albums/${album.id}`}>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
<span>{album.title}</span>
|
||||
<span className="flex-1">{album.title}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 opacity-0 group-hover/album:opacity-100"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
try {
|
||||
await deleteAlbum(album.id)
|
||||
toast.success("Album deleted")
|
||||
} catch {
|
||||
toast.error("Failed to delete album")
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2Icon className="h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ImageViewer } from "./image-viewer"
|
||||
import { AddToAlbumDialog } from "./add-to-album-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { ImagePlusIcon, XIcon, CheckSquareIcon } from "lucide-react"
|
||||
import { ImagePlusIcon, XIcon, CheckSquareIcon, Trash2Icon } from "lucide-react"
|
||||
|
||||
interface PhotoGridProps {
|
||||
groups: DateGroup[]
|
||||
@@ -16,6 +16,7 @@ interface PhotoGridProps {
|
||||
hasMore: boolean
|
||||
onLoadMore: () => void
|
||||
onRemoveAsset?: (assetId: string) => void
|
||||
onDeleteAssets?: (assetIds: string[]) => void
|
||||
}
|
||||
|
||||
export function PhotoGrid({
|
||||
@@ -24,6 +25,7 @@ export function PhotoGrid({
|
||||
hasMore,
|
||||
onLoadMore,
|
||||
onRemoveAsset,
|
||||
onDeleteAssets,
|
||||
}: PhotoGridProps) {
|
||||
const sentinelRef = useRef<HTMLDivElement>(null)
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
|
||||
@@ -94,13 +96,26 @@ export function PhotoGrid({
|
||||
{onRemoveAsset && selectedIds.size > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
selectedIds.forEach((id) => onRemoveAsset(id))
|
||||
exitSelection()
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
Remove from Album
|
||||
</Button>
|
||||
)}
|
||||
{onDeleteAssets && selectedIds.size > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
onDeleteAssets(Array.from(selectedIds))
|
||||
exitSelection()
|
||||
}}
|
||||
>
|
||||
<Trash2Icon className="mr-1.5 h-3.5 w-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="ghost" onClick={exitSelection}>
|
||||
|
||||
@@ -36,6 +36,13 @@ export function useAlbums() {
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["albums"] }),
|
||||
})
|
||||
|
||||
const deleteAlbum = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await api.delete(`/albums/${id}`)
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["albums"] }),
|
||||
})
|
||||
|
||||
const addEntry = useMutation({
|
||||
mutationFn: async ({ albumId, assetId }: { albumId: string; assetId: string }) => {
|
||||
await api.post(`/albums/${albumId}/entries`, { asset_id: assetId })
|
||||
@@ -60,6 +67,7 @@ export function useAlbums() {
|
||||
albums: query.data ?? [],
|
||||
isLoading: query.isLoading,
|
||||
createAlbum: create.mutateAsync,
|
||||
deleteAlbum: deleteAlbum.mutateAsync,
|
||||
updateAlbum: update.mutateAsync,
|
||||
addEntry: addEntry.mutateAsync,
|
||||
removeEntry: removeEntry.mutateAsync,
|
||||
|
||||
Reference in New Issue
Block a user