fix: Add debounced Undo/Redo system to avoid character-by-character history

- Add debounced state updates for title and content (500ms delay)
- Immediate UI updates with delayed history saving
- Prevent one-letter-per-undo issue
- Add cleanup for debounce timers on unmount
This commit is contained in:
2026-01-04 14:28:11 +01:00
parent 355ffb59bb
commit 8d95f34fcc
4106 changed files with 630392 additions and 0 deletions

View File

@@ -0,0 +1,235 @@
'use server'
import { revalidatePath } from 'next/cache'
import prisma from '@/lib/prisma'
import { Note, CheckItem } from '@/lib/types'
// Helper function to parse JSON strings from database
function parseNote(dbNote: any): Note {
return {
...dbNote,
checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null,
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
images: dbNote.images ? JSON.parse(dbNote.images) : null,
}
}
// Get all notes (non-archived by default)
export async function getNotes(includeArchived = false) {
try {
const notes = await prisma.note.findMany({
where: includeArchived ? {} : { isArchived: false },
orderBy: [
{ isPinned: 'desc' },
{ updatedAt: 'desc' }
]
})
return notes.map(parseNote)
} catch (error) {
console.error('Error fetching notes:', error)
return []
}
}
// Get archived notes only
export async function getArchivedNotes() {
try {
const notes = await prisma.note.findMany({
where: { isArchived: true },
orderBy: { updatedAt: 'desc' }
})
return notes.map(parseNote)
} catch (error) {
console.error('Error fetching archived notes:', error)
return []
}
}
// Search notes
export async function searchNotes(query: string) {
try {
if (!query.trim()) {
return await getNotes()
}
const notes = await prisma.note.findMany({
where: {
isArchived: false,
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{ content: { contains: query, mode: 'insensitive' } }
]
},
orderBy: [
{ isPinned: 'desc' },
{ updatedAt: 'desc' }
]
})
return notes.map(parseNote)
} catch (error) {
console.error('Error searching notes:', error)
return []
}
}
// Create a new note
export async function createNote(data: {
title?: string
content: string
color?: string
type?: 'text' | 'checklist'
checkItems?: CheckItem[]
labels?: string[]
images?: string[]
isArchived?: boolean
}) {
try {
const note = await prisma.note.create({
data: {
title: data.title || null,
content: data.content,
color: data.color || 'default',
type: data.type || 'text',
checkItems: data.checkItems ? JSON.stringify(data.checkItems) : null,
labels: data.labels ? JSON.stringify(data.labels) : null,
images: data.images ? JSON.stringify(data.images) : null,
isArchived: data.isArchived || false,
}
})
revalidatePath('/')
return parseNote(note)
} catch (error) {
console.error('Error creating note:', error)
throw new Error('Failed to create note')
}
}
// Update a note
export async function updateNote(id: string, data: {
title?: string | null
content?: string
color?: string
isPinned?: boolean
isArchived?: boolean
type?: 'text' | 'checklist'
checkItems?: CheckItem[] | null
labels?: string[] | null
images?: string[] | null
}) {
try {
// Stringify JSON fields if they exist
const updateData: any = { ...data }
if ('checkItems' in data) {
updateData.checkItems = data.checkItems ? JSON.stringify(data.checkItems) : null
}
if ('labels' in data) {
updateData.labels = data.labels ? JSON.stringify(data.labels) : null
}
if ('images' in data) {
updateData.images = data.images ? JSON.stringify(data.images) : null
}
updateData.updatedAt = new Date()
const note = await prisma.note.update({
where: { id },
data: updateData
})
revalidatePath('/')
return parseNote(note)
} catch (error) {
console.error('Error updating note:', error)
throw new Error('Failed to update note')
}
}
// Delete a note
export async function deleteNote(id: string) {
try {
await prisma.note.delete({
where: { id }
})
revalidatePath('/')
return { success: true }
} catch (error) {
console.error('Error deleting note:', error)
throw new Error('Failed to delete note')
}
}
// Toggle pin status
export async function togglePin(id: string, isPinned: boolean) {
return updateNote(id, { isPinned })
}
// Toggle archive status
export async function toggleArchive(id: string, isArchived: boolean) {
return updateNote(id, { isArchived })
}
// Update note color
export async function updateColor(id: string, color: string) {
return updateNote(id, { color })
}
// Update note labels
export async function updateLabels(id: string, labels: string[]) {
return updateNote(id, { labels })
}
// Get all unique labels
export async function getAllLabels() {
try {
const notes = await prisma.note.findMany({
select: { labels: true }
})
const labelsSet = new Set<string>()
notes.forEach(note => {
const labels = note.labels ? JSON.parse(note.labels) : null
if (labels) {
labels.forEach((label: string) => labelsSet.add(label))
}
})
return Array.from(labelsSet).sort()
} catch (error) {
console.error('Error fetching labels:', error)
return []
}
}
// Reorder notes (drag and drop)
export async function reorderNotes(draggedId: string, targetId: string) {
try {
const draggedNote = await prisma.note.findUnique({ where: { id: draggedId } })
const targetNote = await prisma.note.findUnique({ where: { id: targetId } })
if (!draggedNote || !targetNote) {
throw new Error('Notes not found')
}
// Swap the order values
await prisma.$transaction([
prisma.note.update({
where: { id: draggedId },
data: { order: targetNote.order }
}),
prisma.note.update({
where: { id: targetId },
data: { order: draggedNote.order }
})
])
revalidatePath('/')
return { success: true }
} catch (error) {
console.error('Error reordering notes:', error)
throw new Error('Failed to reorder notes')
}
}

View File

@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
// GET /api/labels - Get all unique labels
export async function GET(request: NextRequest) {
try {
const notes = await prisma.note.findMany({
select: { labels: true }
})
const labelsSet = new Set<string>()
notes.forEach(note => {
const labels = note.labels ? JSON.parse(note.labels) : null
if (labels) {
labels.forEach((label: string) => labelsSet.add(label))
}
})
return NextResponse.json({
success: true,
data: Array.from(labelsSet).sort()
})
} catch (error) {
console.error('GET /api/labels error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to fetch labels' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
// Helper to parse JSON fields
function parseNote(dbNote: any) {
return {
...dbNote,
checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null,
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
}
}
// GET /api/notes/[id] - Get a single note
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const note = await prisma.note.findUnique({
where: { id: params.id }
})
if (!note) {
return NextResponse.json(
{ success: false, error: 'Note not found' },
{ status: 404 }
)
}
return NextResponse.json({
success: true,
data: parseNote(note)
})
} catch (error) {
console.error('GET /api/notes/[id] error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to fetch note' },
{ status: 500 }
)
}
}
// PUT /api/notes/[id] - Update a note
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const body = await request.json()
const updateData: any = { ...body }
// Stringify JSON fields if they exist
if ('checkItems' in body) {
updateData.checkItems = body.checkItems ? JSON.stringify(body.checkItems) : null
}
if ('labels' in body) {
updateData.labels = body.labels ? JSON.stringify(body.labels) : null
}
updateData.updatedAt = new Date()
const note = await prisma.note.update({
where: { id: params.id },
data: updateData
})
return NextResponse.json({
success: true,
data: parseNote(note)
})
} catch (error) {
console.error('PUT /api/notes/[id] error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to update note' },
{ status: 500 }
)
}
}
// DELETE /api/notes/[id] - Delete a note
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
await prisma.note.delete({
where: { id: params.id }
})
return NextResponse.json({
success: true,
message: 'Note deleted successfully'
})
} catch (error) {
console.error('DELETE /api/notes/[id] error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to delete note' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,166 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { CheckItem } from '@/lib/types'
// Helper to parse JSON fields
function parseNote(dbNote: any) {
return {
...dbNote,
checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null,
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
images: dbNote.images ? JSON.parse(dbNote.images) : null,
}
}
// GET /api/notes - Get all notes
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const includeArchived = searchParams.get('archived') === 'true'
const search = searchParams.get('search')
let where: any = {}
if (!includeArchived) {
where.isArchived = false
}
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ content: { contains: search, mode: 'insensitive' } }
]
}
const notes = await prisma.note.findMany({
where,
orderBy: [
{ isPinned: 'desc' },
{ order: 'asc' },
{ updatedAt: 'desc' }
]
})
return NextResponse.json({
success: true,
data: notes.map(parseNote)
})
} catch (error) {
console.error('GET /api/notes error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to fetch notes' },
{ status: 500 }
)
}
}
// POST /api/notes - Create a new note
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { title, content, color, type, checkItems, labels, images } = body
if (!content && type !== 'checklist') {
return NextResponse.json(
{ success: false, error: 'Content is required' },
{ status: 400 }
)
}
const note = await prisma.note.create({
data: {
title: title || null,
content: content || '',
color: color || 'default',
type: type || 'text',
checkItems: checkItems ? JSON.stringify(checkItems) : null,
labels: labels ? JSON.stringify(labels) : null,
images: images ? JSON.stringify(images) : null,
}
})
return NextResponse.json({
success: true,
data: parseNote(note)
}, { status: 201 })
} catch (error) {
console.error('POST /api/notes error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to create note' },
{ status: 500 }
)
}
}
// PUT /api/notes - Update an existing note
export async function PUT(request: NextRequest) {
try {
const body = await request.json()
const { id, title, content, color, type, checkItems, labels, isPinned, isArchived, images } = body
if (!id) {
return NextResponse.json(
{ success: false, error: 'Note ID is required' },
{ status: 400 }
)
}
const updateData: any = {}
if (title !== undefined) updateData.title = title
if (content !== undefined) updateData.content = content
if (color !== undefined) updateData.color = color
if (type !== undefined) updateData.type = type
if (checkItems !== undefined) updateData.checkItems = checkItems ? JSON.stringify(checkItems) : null
if (labels !== undefined) updateData.labels = labels ? JSON.stringify(labels) : null
if (isPinned !== undefined) updateData.isPinned = isPinned
if (isArchived !== undefined) updateData.isArchived = isArchived
if (images !== undefined) updateData.images = images ? JSON.stringify(images) : null
const note = await prisma.note.update({
where: { id },
data: updateData
})
return NextResponse.json({
success: true,
data: parseNote(note)
})
} catch (error) {
console.error('PUT /api/notes error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to update note' },
{ status: 500 }
)
}
}
// DELETE /api/notes?id=xxx - Delete a note
export async function DELETE(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const id = searchParams.get('id')
if (!id) {
return NextResponse.json(
{ success: false, error: 'Note ID is required' },
{ status: 400 }
)
}
await prisma.note.delete({
where: { id }
})
return NextResponse.json({
success: true,
message: 'Note deleted successfully'
})
} catch (error) {
console.error('DELETE /api/notes error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to delete note' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,15 @@
import { getArchivedNotes } from '@/app/actions/notes'
import { NoteGrid } from '@/components/note-grid'
export const dynamic = 'force-dynamic'
export default async function ArchivePage() {
const notes = await getArchivedNotes()
return (
<main className="container mx-auto px-4 py-8 max-w-7xl">
<h1 className="text-3xl font-bold mb-8">Archive</h1>
<NoteGrid notes={notes} />
</main>
)
}

BIN
keep-notes/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

125
keep-notes/app/globals.css Normal file
View File

@@ -0,0 +1,125 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

28
keep-notes/app/layout.tsx Normal file
View File

@@ -0,0 +1,28 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Header } from "@/components/header";
const inter = Inter({
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Memento - Your Digital Notepad",
description: "A beautiful note-taking app inspired by Google Keep, built with Next.js 16",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body className={inter.className}>
<Header />
{children}
</body>
</html>
);
}

23
keep-notes/app/page.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { getNotes, searchNotes } from '@/app/actions/notes'
import { NoteInput } from '@/components/note-input'
import { NoteGrid } from '@/components/note-grid'
export const dynamic = 'force-dynamic'
export default async function HomePage({
searchParams,
}: {
searchParams: Promise<{ search?: string }>
}) {
const params = await searchParams
const notes = params.search
? await searchNotes(params.search)
: await getNotes()
return (
<main className="container mx-auto px-4 py-8 max-w-7xl">
<NoteInput />
<NoteGrid notes={notes} />
</main>
)
}