feat: hierarchical notebook system - trash, selectors, breadcrumb, sidebar tree
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m9s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m9s
- Schema: soft delete with trashedAt on Notebook model - API: PATCH/GET notebooks support trashedAt filtering with cascade - Sidebar: recursive tree rendering with collapse/expand, visual guides, hover actions - HierarchicalNotebookSelector: portal-based dropdown with search, breadcrumbs, dropUp support - AI chat: context selector with Toutes mes notes + notebook selector - Agent detail: flat selects replaced with HierarchicalNotebookSelector - Breadcrumb: notebook path display on home page - Trash view: card grid with countdown, restore/permanent delete - CSS: design tokens (ink, paper, blueprint, concrete, etc.) - Types: parentId, trashedAt added to Notebook interface
This commit is contained in:
@@ -13,8 +13,9 @@ export default async function AgentsPage() {
|
||||
const [agents, notebooks] = await Promise.all([
|
||||
getAgents(),
|
||||
prisma.notebook.findMany({
|
||||
where: { userId },
|
||||
orderBy: { order: 'asc' }
|
||||
where: { userId, trashedAt: null },
|
||||
orderBy: { order: 'asc' },
|
||||
select: { id: true, name: true, icon: true, parentId: true, trashedAt: true }
|
||||
})
|
||||
])
|
||||
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
import { getTrashedNotes } from '@/app/actions/notes'
|
||||
import { MasonryGrid } from '@/components/masonry-grid'
|
||||
import { TrashHeader } from '@/components/trash-header'
|
||||
import { TrashEmptyState } from './trash-empty-state'
|
||||
import { getTrashedNotes, getTrashedNotebooks } from '@/app/actions/notes'
|
||||
import { TrashClient } from './trash-client'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function TrashPage() {
|
||||
const notes = await getTrashedNotes()
|
||||
const [notes, notebooks] = await Promise.all([
|
||||
getTrashedNotes(),
|
||||
getTrashedNotebooks(),
|
||||
])
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
<TrashHeader noteCount={notes.length} />
|
||||
{notes.length > 0 ? (
|
||||
<MasonryGrid notes={notes} isTrashView />
|
||||
) : (
|
||||
<TrashEmptyState />
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
return <TrashClient initialNotes={notes} initialNotebooks={notebooks} />
|
||||
}
|
||||
|
||||
291
memento-note/app/(main)/trash/trash-client.tsx
Normal file
291
memento-note/app/(main)/trash/trash-client.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
'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') || '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') || 'Restored successfully')
|
||||
} catch {
|
||||
toast.error(t('trash.restoreError') || 'Failed to restore')
|
||||
}
|
||||
}
|
||||
|
||||
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') || 'Permanently deleted')
|
||||
} catch {
|
||||
toast.error(t('trash.deleteError') || 'Failed to delete')
|
||||
}
|
||||
}
|
||||
|
||||
const handleEmptyTrash = async () => {
|
||||
if (!window.confirm(t('trash.emptyTrashConfirm') || 'Empty trash? This is irreversible.')) return
|
||||
setIsEmptying(true)
|
||||
try {
|
||||
await emptyTrash()
|
||||
setNotes([])
|
||||
setNotebooks([])
|
||||
toast.success(t('trash.emptyTrashSuccess') || 'Trash emptied')
|
||||
} catch {
|
||||
toast.error(t('trash.deleteError') || 'Failed to empty trash')
|
||||
} 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') || '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') || 'Auto-delete after 30 days'}
|
||||
</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') || 'Empty all'}
|
||||
</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') || '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') || 'All') : type === 'notes' ? (t('nav.notes') || 'Notes') : (t('nav.notebooks') || '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-blueprint'}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div className={`p-3 rounded-2xl ${item.itemType === 'note' ? 'bg-blueprint/10 text-blueprint' : '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') || 'Restore'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePermanentDelete(item)}
|
||||
className="p-2 hover:bg-rose-50 text-rose-500 rounded-xl transition-colors"
|
||||
title={t('trash.permanentDelete') || 'Delete permanently'}
|
||||
>
|
||||
<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-blueprint/20 text-blueprint bg-blueprint/5'}`}>
|
||||
{daysLeft} {t('trash.daysRemaining') || 'DAYS LEFT'}
|
||||
</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') || 'Notebook content preserved'}
|
||||
</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') || 'Trash is empty'}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-xs">
|
||||
{t('trash.emptyDescription') || 'Deleted items will appear here. They are kept for 30 days before permanent deletion.'}
|
||||
</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') || 'Restoring a notebook also restores all its notes.'}
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1053,7 +1053,6 @@ export async function emptyTrash() {
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
try {
|
||||
// Fetch trashed notes with images before deleting
|
||||
const trashedNotes = await prisma.note.findMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
@@ -1069,7 +1068,6 @@ export async function emptyTrash() {
|
||||
}
|
||||
})
|
||||
|
||||
// Clean up image files for all deleted notes
|
||||
for (const note of trashedNotes) {
|
||||
const imageUrls = parseImageUrls(note.images)
|
||||
if (imageUrls.length > 0) {
|
||||
@@ -1077,6 +1075,13 @@ export async function emptyTrash() {
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.notebook.deleteMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
trashedAt: { not: null }
|
||||
}
|
||||
})
|
||||
|
||||
await syncLabels(session.user.id, [])
|
||||
revalidatePath('/trash')
|
||||
revalidatePath('/')
|
||||
@@ -1137,14 +1142,44 @@ export async function getTrashCount() {
|
||||
if (!session?.user?.id) return 0;
|
||||
|
||||
try {
|
||||
return await prisma.note.count({
|
||||
const [noteCount, notebookCount] = await Promise.all([
|
||||
prisma.note.count({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
trashedAt: { not: null }
|
||||
}
|
||||
}),
|
||||
prisma.notebook.count({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
trashedAt: { not: null }
|
||||
}
|
||||
})
|
||||
])
|
||||
return noteCount + notebookCount
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTrashedNotebooks() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return [];
|
||||
|
||||
try {
|
||||
return await prisma.notebook.findMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
trashedAt: { not: null }
|
||||
}
|
||||
},
|
||||
include: {
|
||||
_count: { select: { notes: true } }
|
||||
},
|
||||
orderBy: { trashedAt: 'desc' }
|
||||
})
|
||||
} catch {
|
||||
return 0
|
||||
} catch (error) {
|
||||
console.error('Error fetching trashed notebooks:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,19 @@ import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
// PATCH /api/notebooks/[id] - Update a notebook
|
||||
async function getDescendantIds(notebookId: string): Promise<string[]> {
|
||||
const ids: string[] = []
|
||||
const children = await prisma.notebook.findMany({
|
||||
where: { parentId: notebookId },
|
||||
select: { id: true },
|
||||
})
|
||||
for (const child of children) {
|
||||
ids.push(child.id)
|
||||
ids.push(...await getDescendantIds(child.id))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
@@ -16,9 +28,8 @@ export async function PATCH(
|
||||
try {
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
const { name, icon, color, order } = body
|
||||
const { name, icon, color, order, trashedAt } = body
|
||||
|
||||
// Verify ownership
|
||||
const existing = await prisma.notebook.findUnique({
|
||||
where: { id },
|
||||
select: { userId: true }
|
||||
@@ -38,32 +49,30 @@ export async function PATCH(
|
||||
)
|
||||
}
|
||||
|
||||
// Build update data
|
||||
const updateData: any = {}
|
||||
if (name !== undefined) updateData.name = name.trim()
|
||||
if (icon !== undefined) updateData.icon = icon
|
||||
if (color !== undefined) updateData.color = color
|
||||
if (order !== undefined) updateData.order = order
|
||||
if (trashedAt !== undefined) updateData.trashedAt = trashedAt
|
||||
|
||||
// Update notebook
|
||||
const notebook = await prisma.notebook.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: {
|
||||
labels: true,
|
||||
_count: {
|
||||
select: { notes: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
if (trashedAt !== undefined) {
|
||||
const descendantIds = await getDescendantIds(id)
|
||||
const allIds = [id, ...descendantIds]
|
||||
await prisma.notebook.updateMany({
|
||||
where: { id: { in: allIds }, userId: session.user.id },
|
||||
data: { trashedAt },
|
||||
})
|
||||
} else {
|
||||
await prisma.notebook.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
})
|
||||
}
|
||||
|
||||
revalidatePath('/')
|
||||
try { revalidatePath('/') } catch {}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...notebook,
|
||||
notesCount: notebook._count.notes
|
||||
})
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error updating notebook:', error)
|
||||
return NextResponse.json(
|
||||
@@ -73,7 +82,6 @@ export async function PATCH(
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/notebooks/[id] - Delete a notebook
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
@@ -86,16 +94,9 @@ export async function DELETE(
|
||||
try {
|
||||
const { id } = await params
|
||||
|
||||
// Verify ownership and get notebook info
|
||||
const notebook = await prisma.notebook.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
userId: true,
|
||||
name: true,
|
||||
_count: {
|
||||
select: { notes: true, labels: true }
|
||||
}
|
||||
}
|
||||
select: { userId: true, name: true }
|
||||
})
|
||||
|
||||
if (!notebook) {
|
||||
@@ -112,18 +113,13 @@ export async function DELETE(
|
||||
)
|
||||
}
|
||||
|
||||
// Delete notebook (cascade will handle labels and notes)
|
||||
await prisma.notebook.delete({
|
||||
where: { id }
|
||||
})
|
||||
await prisma.notebook.delete({ where: { id } })
|
||||
|
||||
revalidatePath('/')
|
||||
try { revalidatePath('/') } catch {}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Notebook "${notebook.name}" deleted`,
|
||||
notesCount: notebook._count.notes,
|
||||
labelsCount: notebook._count.labels
|
||||
message: `Notebook "${notebook.name}" permanently deleted`,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error deleting notebook:', error)
|
||||
|
||||
@@ -31,7 +31,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
try {
|
||||
const notebooks = await prisma.notebook.findMany({
|
||||
where: { userId: session.user.id },
|
||||
where: { userId: session.user.id, trashedAt: null },
|
||||
include: {
|
||||
labels: { orderBy: { name: 'asc' } },
|
||||
_count: {
|
||||
|
||||
@@ -24,6 +24,17 @@
|
||||
--color-background-light: var(--color-memento-paper);
|
||||
--color-background-dark: #202020;
|
||||
|
||||
/* Design tokens from architectural-grid 10 */
|
||||
--color-ink: #1C1C1C;
|
||||
--color-paper: #F2F0E9;
|
||||
--color-muted-ink: rgba(28, 28, 28, 0.6);
|
||||
--color-concrete: #8D8D8D;
|
||||
--color-blueprint: #75B2D6;
|
||||
--color-ochre: #D4A373;
|
||||
--color-sage: #A3B18A;
|
||||
--color-rust: #9B2226;
|
||||
--color-glass: rgba(255, 255, 255, 0.4);
|
||||
|
||||
--font-sans: var(--font-inter);
|
||||
--font-heading: var(--font-memento-serif), ui-serif, Georgia, "Times New Roman", serif;
|
||||
--shadow-level-1: 0 2px 4px rgba(0, 0, 0, 0.04), 0 4px 8px rgba(0, 0, 0, 0.06);
|
||||
|
||||
Reference in New Issue
Block a user