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

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

View File

@@ -16,7 +16,7 @@ import { AlbumSidebar } from "@/components/album-sidebar"
import { AdminSidebar } from "@/components/admin-sidebar"
import { UploadDialog } from "@/components/upload-dialog"
import { Spinner } from "@/components/ui/spinner"
import { CameraIcon, LogOutIcon } from "lucide-react"
import { CameraIcon, LogOutIcon, Trash2Icon } from "lucide-react"
import { Button } from "@/components/ui/button"
import Link from "next/link"
@@ -53,13 +53,22 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
<AlbumSidebar />
<AdminSidebar />
</SidebarContent>
<div className="flex items-center justify-between border-t px-4 py-2">
<span className="truncate text-xs text-muted-foreground">
{user?.username}
</span>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={logout}>
<LogOutIcon className="h-3.5 w-3.5" />
</Button>
<div className="flex flex-col border-t">
<Link
href="/trash"
className="flex items-center gap-2 px-4 py-2 text-xs text-muted-foreground hover:text-foreground"
>
<Trash2Icon className="h-3.5 w-3.5" />
Trash
</Link>
<div className="flex items-center justify-between px-4 py-2">
<span className="truncate text-xs text-muted-foreground">
{user?.username}
</span>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={logout}>
<LogOutIcon className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</Sidebar>
<SidebarInset>

View File

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

View File

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