All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 4s
Covers architecture, configuration steps, user flows, API routes, webhooks, pricing, testing with Stripe CLI, production checklist, and troubleshooting.
293 lines
12 KiB
TypeScript
293 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useMemo } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { motion, AnimatePresence } from 'motion/react'
|
|
|
|
import {
|
|
Trash2,
|
|
RotateCcw,
|
|
X,
|
|
FileText,
|
|
Folder,
|
|
Search,
|
|
Clock,
|
|
AlertCircle,
|
|
} from 'lucide-react'
|
|
import { Note, Notebook } from '@/lib/types'
|
|
import { restoreNote, permanentDeleteNote, emptyTrash } from '@/app/actions/notes'
|
|
import { restoreNotebook, permanentDeleteNotebook } from '@/context/notebooks-context'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import { toast } from 'sonner'
|
|
import { useNotebooks } from '@/context/notebooks-context'
|
|
|
|
type FilterType = 'all' | 'notes' | 'notebooks'
|
|
|
|
interface TrashItem {
|
|
id: string
|
|
title: string
|
|
itemType: 'note' | 'notebook'
|
|
deletedAt: string | null
|
|
content?: string
|
|
notesCount?: number
|
|
}
|
|
|
|
function getDaysRemaining(deletedAt?: string | null): number {
|
|
if (!deletedAt) return 30
|
|
const deletedDate = new Date(deletedAt)
|
|
const now = new Date()
|
|
const diffTime = now.getTime() - deletedDate.getTime()
|
|
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24))
|
|
return Math.max(0, 30 - diffDays)
|
|
}
|
|
|
|
export function TrashClient({
|
|
initialNotes,
|
|
initialNotebooks,
|
|
}: {
|
|
initialNotes: Note[]
|
|
initialNotebooks: any[]
|
|
}) {
|
|
const { t } = useLanguage()
|
|
const router = useRouter()
|
|
const { restoreNotebook: restoreNb, permanentDeleteNotebook: permDeleteNb } = useNotebooks()
|
|
|
|
const [notes, setNotes] = useState(initialNotes)
|
|
const [notebooks, setNotebooks] = useState(initialNotebooks)
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const [filterType, setFilterType] = useState<FilterType>('all')
|
|
const [isEmptying, setIsEmptying] = useState(false)
|
|
|
|
const items: TrashItem[] = useMemo(() => {
|
|
const all: TrashItem[] = [
|
|
...notes.map(n => ({
|
|
id: n.id,
|
|
title: n.title || t('notes.untitled'),
|
|
itemType: 'note' as const,
|
|
deletedAt: (n as any).trashedAt || null,
|
|
content: n.content,
|
|
})),
|
|
...notebooks.map((nb: any) => ({
|
|
id: nb.id,
|
|
title: nb.name,
|
|
itemType: 'notebook' as const,
|
|
deletedAt: nb.trashedAt || null,
|
|
notesCount: nb._count?.notes ?? 0,
|
|
})),
|
|
]
|
|
|
|
return all
|
|
.filter(item => {
|
|
const matchesSearch = item.title.toLowerCase().includes(searchQuery.toLowerCase())
|
|
const matchesType =
|
|
filterType === 'all' ||
|
|
(filterType === 'notes' && item.itemType === 'note') ||
|
|
(filterType === 'notebooks' && item.itemType === 'notebook')
|
|
return matchesSearch && matchesType
|
|
})
|
|
.sort((a, b) => {
|
|
const dateA = a.deletedAt ? new Date(a.deletedAt).getTime() : 0
|
|
const dateB = b.deletedAt ? new Date(b.deletedAt).getTime() : 0
|
|
return dateB - dateA
|
|
})
|
|
}, [notes, notebooks, searchQuery, filterType, t])
|
|
|
|
const handleRestore = async (item: TrashItem) => {
|
|
try {
|
|
if (item.itemType === 'note') {
|
|
await restoreNote(item.id)
|
|
setNotes(prev => prev.filter(n => n.id !== item.id))
|
|
} else {
|
|
await restoreNb(item.id)
|
|
setNotebooks(prev => prev.filter((nb: any) => nb.id !== item.id))
|
|
}
|
|
toast.success(t('trash.restoreSuccess'))
|
|
} catch {
|
|
toast.error(t('trash.restoreError'))
|
|
}
|
|
}
|
|
|
|
const handlePermanentDelete = async (item: TrashItem) => {
|
|
try {
|
|
if (item.itemType === 'note') {
|
|
await permanentDeleteNote(item.id)
|
|
setNotes(prev => prev.filter(n => n.id !== item.id))
|
|
} else {
|
|
await permDeleteNb(item.id)
|
|
setNotebooks(prev => prev.filter((nb: any) => nb.id !== item.id))
|
|
}
|
|
toast.success(t('trash.permanentDeleteSuccess'))
|
|
} catch {
|
|
toast.error(t('trash.deleteError'))
|
|
}
|
|
}
|
|
|
|
const handleEmptyTrash = async () => {
|
|
if (!window.confirm(t('trash.emptyTrashConfirm'))) return
|
|
setIsEmptying(true)
|
|
try {
|
|
await emptyTrash()
|
|
setNotes([])
|
|
setNotebooks([])
|
|
toast.success(t('trash.emptyTrashSuccess'))
|
|
} catch {
|
|
toast.error(t('trash.deleteError'))
|
|
} finally {
|
|
setIsEmptying(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="h-full flex flex-col bg-[#F9F8F6] dark:bg-[#1a1a1a]">
|
|
<header className="px-12 pt-12 pb-8 flex flex-col gap-6 sticky top-0 bg-[#F9F8F6]/80 dark:bg-[#1a1a1a]/80 backdrop-blur-md z-30 border-b border-border/20">
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-1">
|
|
<h1 className="text-4xl font-memento-serif font-medium text-foreground flex items-center gap-4">
|
|
{t('sidebar.trash')} <Trash2 size={28} className="text-rose-400 opacity-40" />
|
|
</h1>
|
|
<p className="text-[10px] text-muted-foreground font-bold uppercase tracking-[0.3em] opacity-60">
|
|
{t('trash.autoDelete30')}
|
|
</p>
|
|
</div>
|
|
|
|
{items.length > 0 && (
|
|
<button
|
|
onClick={handleEmptyTrash}
|
|
disabled={isEmptying}
|
|
className="px-6 py-3 bg-card border border-border text-rose-500 rounded-2xl text-[10px] font-bold uppercase tracking-widest hover:bg-rose-50 hover:border-rose-100 transition-all shadow-sm disabled:opacity-50"
|
|
>
|
|
{t('trash.emptyTrash')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-6">
|
|
<div className="group relative flex-1 max-w-xl">
|
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-muted-foreground group-focus-within:text-foreground transition-colors" size={16} />
|
|
<input
|
|
type="text"
|
|
placeholder={t('common.search')}
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="w-full bg-white dark:bg-white/5 border border-border/40 rounded-2xl pl-12 pr-6 py-4 text-sm outline-none focus:ring-4 ring-foreground/5 transition-all shadow-sm"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex bg-white dark:bg-white/5 p-1 rounded-2xl border border-border shadow-sm">
|
|
{(['all', 'notes', 'notebooks'] as FilterType[]).map(type => (
|
|
<button
|
|
key={type}
|
|
onClick={() => setFilterType(type)}
|
|
className={`px-6 py-3 rounded-xl text-[10px] font-bold uppercase tracking-widest transition-all
|
|
${filterType === type ? 'bg-foreground text-background shadow-lg' : 'text-muted-foreground hover:text-foreground'}`}
|
|
>
|
|
{type === 'all' ? t('trash.filterAll') : type === 'notes' ? t('nav.notes') : t('nav.notebooks')}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="flex-1 px-12 py-12 overflow-y-auto">
|
|
{items.length > 0 ? (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
|
<AnimatePresence mode="popLayout">
|
|
{items.map(item => {
|
|
const daysLeft = getDaysRemaining(item.deletedAt)
|
|
return (
|
|
<motion.div
|
|
key={item.id}
|
|
layout
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.9 }}
|
|
className="bg-white dark:bg-white/5 border border-border/60 rounded-[32px] p-8 group hover:shadow-2xl hover:border-foreground/20 transition-all relative overflow-hidden flex flex-col"
|
|
>
|
|
<div className="absolute top-0 left-0 w-full h-1 bg-slate-100 dark:bg-white/5 overflow-hidden">
|
|
<motion.div
|
|
initial={{ width: 0 }}
|
|
animate={{ width: `${(daysLeft / 30) * 100}%` }}
|
|
className={`h-full ${daysLeft < 5 ? 'bg-rose-500' : 'bg-terreo'}`}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex justify-between items-start mb-6">
|
|
<div className={`p-3 rounded-2xl ${item.itemType === 'note' ? 'bg-terreo/10 text-terreo' : 'bg-concrete/10 text-concrete'}`}>
|
|
{item.itemType === 'note' ? <FileText size={20} /> : <Folder size={20} />}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => handleRestore(item)}
|
|
className="flex items-center gap-2 px-4 py-2 bg-emerald-50 text-emerald-600 rounded-xl text-[10px] font-bold uppercase tracking-widest hover:bg-emerald-100 transition-colors"
|
|
>
|
|
<RotateCcw size={12} /> {t('trash.restore')}
|
|
</button>
|
|
<button
|
|
onClick={() => handlePermanentDelete(item)}
|
|
className="p-2 hover:bg-rose-50 text-rose-500 rounded-xl transition-colors"
|
|
title={t('trash.permanentDelete')}
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2 mb-8 flex-1">
|
|
<h3 className="text-base font-memento-serif font-medium text-foreground leading-tight">
|
|
{item.title}
|
|
</h3>
|
|
<div className="flex items-center gap-3">
|
|
<div className={`text-[9px] font-bold uppercase tracking-widest px-2 py-0.5 rounded border ${daysLeft < 5 ? 'border-rose-200 text-rose-500 bg-rose-50' : 'border-terreo/20 text-terreo bg-terreo/5'}`}>
|
|
{daysLeft} {t('trash.daysRemaining')}
|
|
</div>
|
|
{item.deletedAt && (
|
|
<span className="text-[10px] text-muted-foreground font-medium uppercase tracking-tight flex items-center gap-1">
|
|
<Clock size={10} /> {new Date(item.deletedAt).toLocaleDateString()}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{item.itemType === 'note' && item.content ? (
|
|
<div className="text-[12px] text-muted-foreground line-clamp-3 leading-relaxed opacity-60 font-light border-t border-border/40 pt-4">
|
|
{item.content.replace(/[#*`]/g, '').slice(0, 200)}
|
|
</div>
|
|
) : (
|
|
<div className="border-t border-border/40 pt-4">
|
|
<div className="text-[9px] font-bold text-muted-foreground/40 uppercase tracking-widest">
|
|
{t('trash.notebookContentPreserved')}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
)
|
|
})}
|
|
</AnimatePresence>
|
|
</div>
|
|
) : (
|
|
<div className="h-full flex flex-col items-center justify-center text-center space-y-6 opacity-40">
|
|
<div className="p-8 rounded-full bg-slate-100 border-2 border-dashed border-border flex items-center justify-center">
|
|
<Trash2 size={64} className="text-muted-foreground" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<h2 className="text-2xl font-memento-serif text-foreground italic">
|
|
{t('trash.empty')}
|
|
</h2>
|
|
<p className="text-sm text-muted-foreground max-w-xs">
|
|
{t('trash.emptyDescription')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</main>
|
|
|
|
<footer className="px-12 py-6 bg-white/50 dark:bg-white/5 border-t border-border flex items-center gap-4">
|
|
<AlertCircle size={14} className="text-muted-foreground" />
|
|
<p className="text-[10px] text-muted-foreground font-medium uppercase tracking-widest">
|
|
{t('trash.notebookRestoreHint')}
|
|
</p>
|
|
</footer>
|
|
</div>
|
|
)
|
|
}
|