- 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
88 lines
2.7 KiB
TypeScript
88 lines
2.7 KiB
TypeScript
"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>
|
|
)
|
|
}
|