From 917e460a96565e5bfe1b20542781fa94111a341d Mon Sep 17 00:00:00 2001 From: misterkirill Date: Fri, 11 Jul 2025 17:27:28 +0500 Subject: [PATCH] feat: start editor database integration --- src/app/actions/blocks.ts | 51 +++++--- src/{lib => app/actions}/notes.ts | 48 +++++--- src/app/not-found.tsx | 8 ++ src/app/notes/[id]/page.tsx | 12 +- src/app/notes/new/page.tsx | 17 --- src/app/notes/page.tsx | 2 +- src/components/Header.tsx | 5 +- src/components/editor/Block.tsx | 53 ++++----- src/components/editor/Editor.tsx | 68 +++++++---- src/components/ui/NoteCard.tsx | 14 +-- src/lib/db/schema.ts | 16 ++- src/lib/editorReducer.ts | 192 ------------------------------ src/lib/hooks/useDebounce.ts | 9 ++ 13 files changed, 175 insertions(+), 320 deletions(-) rename src/{lib => app/actions}/notes.ts (66%) create mode 100644 src/app/not-found.tsx delete mode 100644 src/app/notes/new/page.tsx delete mode 100644 src/lib/editorReducer.ts create mode 100644 src/lib/hooks/useDebounce.ts diff --git a/src/app/actions/blocks.ts b/src/app/actions/blocks.ts index a2c18fb..4f31bf6 100644 --- a/src/app/actions/blocks.ts +++ b/src/app/actions/blocks.ts @@ -1,23 +1,42 @@ "use server"; -import { eq } from "drizzle-orm"; -import { blocksTable } from "@/lib/db/schema"; +import { revalidatePath } from "next/cache"; +import { eq, and } from "drizzle-orm"; +import { blocksTable, IBlock, INote } from "@/lib/db/schema"; import { db } from "@/lib/db"; -export async function updateBlock({ - id, - tag, - lines, -}: { - id: string; - tag?: string; - lines?: string[]; -}) { +export async function createBlock(note: INote) { + await db + .insert(blocksTable) + .values({ noteId: note.id }); + + revalidatePath("/blocks/[id]"); +} + +export async function getBlocks(note: INote) { + return db.select() + .from(blocksTable) + .where(and(eq(blocksTable.noteId, note.id))); +} + +export async function updateBlockTag(block: IBlock, newTag: string) { await db .update(blocksTable) - .set({ - ...(tag !== undefined ? { tag } : {}), - ...(lines !== undefined ? { lines } : {}), - }) - .where(eq(blocksTable.id, id)); + .set({ tag: newTag }) + .where(eq(blocksTable.id, block.id)); +} + +export async function deleteBlock(block: IBlock) { + await db.delete(blocksTable) + .where(and(eq(blocksTable.id, block.id))); + + revalidatePath("/blocks/[id]"); +} + +export async function switchLock(block: IBlock) { + await db.update(blocksTable) + .set({ isLocked: !block.isLocked }) + .where(and(eq(blocksTable.id, block.id))); + + revalidatePath("/blocks/[id]"); } diff --git a/src/lib/notes.ts b/src/app/actions/notes.ts similarity index 66% rename from src/lib/notes.ts rename to src/app/actions/notes.ts index de1d189..c5320b5 100644 --- a/src/lib/notes.ts +++ b/src/app/actions/notes.ts @@ -1,8 +1,26 @@ -import { desc, eq, and } from "drizzle-orm"; +"use server"; + +import { redirect } from "next/navigation"; +import { revalidatePath } from "next/cache"; import { validate as uuidValidate } from "uuid"; -import { notesTable } from "./db/schema"; -import { getAuth } from "./auth"; -import { db } from "./db"; +import { notesTable } from "@/lib/db/schema"; +import { getAuth } from "@/lib/auth"; +import { eq, and, desc } from "drizzle-orm"; +import { db } from "@/lib/db"; + +export async function createNote() { + const auth = await getAuth(); + if (!auth) { + redirect("/auth"); + } + + const notes = await db + .insert(notesTable) + .values({ authorId: auth.id }) + .returning({ id: notesTable.id }); + + redirect(`/notes/${notes[0].id}`); +} export async function getNotes(authorId: string) { return db.select() @@ -32,20 +50,6 @@ export async function getNote(noteId: string) { } } -export async function createNote() { - const auth = await getAuth(); - if (!auth) { - return null; - } - - const note = await db - .insert(notesTable) - .values({ authorId: auth.id }) - .returning({ id: notesTable.id }); - - return note[0].id; -} - export async function deleteNote(noteId: string) { if (!uuidValidate(noteId)) { return null; @@ -57,4 +61,12 @@ export async function deleteNote(noteId: string) { } await db.delete(notesTable).where(eq(notesTable.id, noteId)); + revalidatePath("/notes"); +} + +export async function updateTitle(noteId: string, title: string) { + await db + .update(notesTable) + .set({ title }) + .where(eq(notesTable.id, noteId)); } diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..e374fbb --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,8 @@ +export default function NotFound() { + return ( +
+

404

+

Page not found

+
+ ); +} diff --git a/src/app/notes/[id]/page.tsx b/src/app/notes/[id]/page.tsx index 9bdeeb7..438da79 100644 --- a/src/app/notes/[id]/page.tsx +++ b/src/app/notes/[id]/page.tsx @@ -1,6 +1,7 @@ import { Metadata } from "next"; import { notFound } from "next/navigation"; -import { getNote } from "@/lib/notes"; +import { getBlocks } from "@/app/actions/blocks"; +import { getNote } from "@/app/actions/notes"; import Editor from "@/components/editor/Editor"; export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise { @@ -10,7 +11,7 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin notFound(); } - return { title: note.name }; + return { title: note.title }; } export default async function Note({ params }: { params: Promise<{ id: string }> }) { @@ -19,10 +20,15 @@ export default async function Note({ params }: { params: Promise<{ id: string }> if (!note) { notFound(); } + + const blocks = await getBlocks(note); + if (!blocks) { + notFound(); + } return (
- +
); } diff --git a/src/app/notes/new/page.tsx b/src/app/notes/new/page.tsx deleted file mode 100644 index 7ee0104..0000000 --- a/src/app/notes/new/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { redirect } from "next/navigation"; -import { createNote } from "@/lib/notes"; - -export default async function NewNote() { - const noteId = await createNote(); - - if (noteId) { - redirect(`/notes/${noteId}`); - } - - return ( -
- Failed to create a new note!
- Please, try again. -
- ); -} diff --git a/src/app/notes/page.tsx b/src/app/notes/page.tsx index d396fca..08bc2c8 100644 --- a/src/app/notes/page.tsx +++ b/src/app/notes/page.tsx @@ -1,6 +1,6 @@ import { Metadata } from "next"; import { redirect } from "next/navigation"; -import { getNotes } from "@/lib/notes"; +import { getNotes } from "@/app/actions/notes"; import { getAuth } from "@/lib/auth"; import NoteCard from "@/components/ui/NoteCard"; diff --git a/src/components/Header.tsx b/src/components/Header.tsx index bb3f47c..6c4b810 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; import { CircleQuestionMark, List, Plus, UserRound, UserRoundMinus } from "lucide-react"; +import { createNote } from "@/app/actions/notes"; import { logOut } from "@/app/actions/auth"; import { getAuth } from "@/lib/auth"; import HeaderButton from "./ui/HeaderButton"; @@ -15,9 +16,7 @@ export default async function Header() { {auth && (
- - } /> - + } /> } /> diff --git a/src/components/editor/Block.tsx b/src/components/editor/Block.tsx index 214f765..d511a0a 100644 --- a/src/components/editor/Block.tsx +++ b/src/components/editor/Block.tsx @@ -2,47 +2,34 @@ import { ChangeEvent } from "react"; import { ArrowDown, ArrowUp, Lock, LockOpen, Minus, Plus, X } from "lucide-react"; -import { Action, IBlock, ILine } from "@/lib/editorReducer"; +import { IBlock } from "@/lib/db/schema"; +import { switchLock, deleteBlock } from "@/app/actions/blocks"; import IconOnlyButton from "../ui/IconOnlyButton"; import LineInput from "./LineInput"; -export default function Block({ - block, - dispatch, -}: { - block: IBlock; - dispatch: React.Dispatch; -}) { +export default function Block({ block }: { block: IBlock }) { const handleAddLine = () => { - dispatch({ type: "add_line", blockId: block.id }); + // TODO } const handleDeleteLine = () => { - dispatch({ type: "delete_line", blockId: block.id }); + // TODO } const handleTagUpdate = (e: ChangeEvent) => { - dispatch({ type: "update_tag", blockId: block.id, tag: e.target.value }); + // TODO } - const handleLineUpdate = (e: ChangeEvent, line: ILine) => { - dispatch({ type: "update_line_text", blockId: block.id, lineId: line.id, text: e.target.value }); - } - - const handleDeleteBlock = () => { - dispatch({ type: "delete_block", blockId: block.id }); + const handleLineUpdate = (e: ChangeEvent, line: string) => { + // TODO } const handleBlockUp = () => { - dispatch({ type: "move_block_up", blockId: block.id }); + // TODO } const handleBlockDown = () => { - dispatch({ type: "move_block_down", blockId: block.id }); - } - - const handleToggleLock = () => { - dispatch({ type: "toggle_lock", blockId: block.id }); + // TODO } return ( @@ -52,15 +39,15 @@ export default function Block({ type="text" placeholder="enter tag..." className="w-full focus:outline-none" - value={block.tag} - disabled={block.locked} + defaultValue={block.tag} + disabled={block.isLocked} onChange={handleTagUpdate} /> - {block.lines.map((line) => ( + {block.lines.map((line, i) => ( handleLineUpdate(e, line)} /> ))} @@ -74,8 +61,12 @@ export default function Block({ } />
- } /> - : } alwaysOn={block.locked} /> + deleteBlock(block)} icon={} /> + switchLock(block)} + icon={block.isLocked ? : } + alwaysOn={block.isLocked} + />
diff --git a/src/components/editor/Editor.tsx b/src/components/editor/Editor.tsx index ae99aec..bb461d9 100644 --- a/src/components/editor/Editor.tsx +++ b/src/components/editor/Editor.tsx @@ -1,53 +1,69 @@ "use client"; -import { ChangeEvent, useReducer } from "react"; +import { ChangeEvent, useState } from "react"; import { Copy, Plus } from "lucide-react"; import { v4 as uuidv4 } from "uuid"; -import { editorReducer, IBlock } from "@/lib/editorReducer"; +import { createBlock } from "@/app/actions/blocks"; +import { IBlock, INote } from "@/lib/db/schema"; import IconOnlyButton from "../ui/IconOnlyButton"; import Block from "./Block"; interface EditorProps { - defaultTitle?: string; - defaultBlocks?: IBlock[]; + note? : INote; + blocks? : IBlock[]; } -const defaultBlocks = [ +const defaultNoteId = uuidv4(); + +const defaultNote: INote = { + id: defaultNoteId, + title: "Untitled", + creationTime: new Date(), + lastEdited: new Date(), + authorId: uuidv4(), +}; + +const defaultBlocks: IBlock[] = [ { id: uuidv4(), tag: "", - locked: false, - lines: Array.from({ length: 4 }, () => ({ - id: uuidv4(), - text: "", - })), - } + order: 1, + isLocked: false, + noteId: defaultNoteId, + lines: ["", "", "", ""], + }, ]; -export default function Editor(props: EditorProps) { - const [state, dispatch] = useReducer(editorReducer, { - title: props.defaultTitle || "Untitled", - blocks: props.defaultBlocks || defaultBlocks, - }); +export default function Editor({ note = defaultNote, blocks = defaultBlocks }: EditorProps) { + const [title, setTitle] = useState(note.title); const handleUpdateTitle = (e: ChangeEvent) => { - dispatch({ type: "update_title", title: e.target.value }); - } - - const handleAddBlock = () => { - dispatch({ type: "add_block" }); + setTitle(e.target.value); } const handleCopy = () => { - dispatch({ type: "copy" }); + let copyString = ""; + + blocks.forEach((block) => { + if (block.tag !== "") { + copyString += `[${block.tag}]`; + } + copyString += block.lines + "\n"; + }); + + navigator.clipboard.writeText(copyString); } return ( -
- - {state.blocks.map((block) => )} +
+ + {blocks.map((block) => )}
- } /> + createBlock(note)} icon={} /> } title="Copy note to clipboard" />
diff --git a/src/components/ui/NoteCard.tsx b/src/components/ui/NoteCard.tsx index b0c2ac4..5964a03 100644 --- a/src/components/ui/NoteCard.tsx +++ b/src/components/ui/NoteCard.tsx @@ -1,8 +1,7 @@ import Link from "next/link"; -import { revalidatePath } from "next/cache"; import { X } from "lucide-react"; -import { notesTable } from "@/lib/db/schema"; -import { deleteNote } from "@/lib/notes"; +import { INote } from "@/lib/db/schema"; +import { deleteNote } from "@/app/actions/notes"; import IconOnlyButton from "./IconOnlyButton"; function makeTimestamp(date: Date) { @@ -14,21 +13,20 @@ function makeTimestamp(date: Date) { return `${day}.${month}.${year} ${hours}:${minutes}`; } -export default function NoteCard({ note }: { note: typeof notesTable.$inferSelect }) { - const handleDeleteNote = async () => { +export default function NoteCard({ note }: { note: INote }) { + const handleDelete = async () => { "use server"; await deleteNote(note.id); - revalidatePath("/notes"); } return (
-

{note.name}

+

{note.title}

Last time edited: {makeTimestamp(note.lastEdited)} Creation date: {makeTimestamp(note.creationTime)} - } onClick={handleDeleteNote} /> + } onClick={handleDelete} />
); } diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index fa05a09..90b8da4 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -1,4 +1,4 @@ -import { pgTable, json, varchar, timestamp, uuid } from "drizzle-orm/pg-core"; +import { pgTable, varchar, timestamp, uuid, boolean, integer, json } from "drizzle-orm/pg-core"; import { relations } from "drizzle-orm"; export const usersTable = pgTable("users", { @@ -13,10 +13,10 @@ export const usersRelations = relations(usersTable, ({ many }) => ({ export const notesTable = pgTable("notes", { id: uuid().primaryKey().defaultRandom(), - name: varchar({ length: 50 }).notNull().default("Untitled"), + title: varchar({ length: 50 }).notNull().default("Untitled"), creationTime: timestamp().notNull().defaultNow(), lastEdited: timestamp().notNull().defaultNow(), - authorId: uuid(), + authorId: uuid().notNull(), }); export const notesRelations = relations(notesTable, ({ one }) => ({ @@ -29,8 +29,10 @@ export const notesRelations = relations(notesTable, ({ one }) => ({ export const blocksTable = pgTable("blocks", { id: uuid().primaryKey().defaultRandom(), tag: varchar({ length: 100 }).notNull().default(""), - lines: json().notNull().default([]), - noteId: uuid(), + lines: json().notNull().default(["", "", "", ""]).$type(), + isLocked: boolean().notNull().default(false), + order: integer().notNull().default(1), + noteId: uuid().notNull(), }); export const blocksRelations = relations(blocksTable, ({ one }) => ({ @@ -39,3 +41,7 @@ export const blocksRelations = relations(blocksTable, ({ one }) => ({ references: [notesTable.id], }), })); + +export type IUser = typeof usersTable.$inferSelect; +export type INote = typeof notesTable.$inferSelect; +export type IBlock = typeof blocksTable.$inferSelect; diff --git a/src/lib/editorReducer.ts b/src/lib/editorReducer.ts deleted file mode 100644 index 62be492..0000000 --- a/src/lib/editorReducer.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { v4 as uuidv4 } from "uuid"; - -export type ILine = { - id: string; - text: string; -}; - -export type IBlock = { - id: string; - tag: string; - locked: boolean; - lines: ILine[]; -}; - -type EditorState = { - title: string; - blocks: IBlock[]; -}; - -export type Action = - | { type: "add_block" } - | { type: "copy" } - | { type: "delete_block", blockId: string } - | { type: "add_line", blockId: string } - | { type: "delete_line", blockId: string } - | { type: "update_title", title: string } - | { type: "update_line_text"; blockId: string; lineId: string; text: string } - | { type: "update_tag"; blockId: string; tag: string } - | { type: "move_block_up", blockId: string } - | { type: "move_block_down", blockId: string } - | { type: "toggle_lock", blockId: string }; - -export function editorReducer(state: EditorState, action: Action): EditorState { - switch (action.type) { - case "update_title": - return { - ...state, - title: action.title, - }; - - case "add_block": - return { - ...state, - blocks: [ - ...state.blocks, - { - id: uuidv4(), - tag: "", - locked: false, - lines: Array.from({ length: 4 }, () => ({ - id: uuidv4(), - text: "", - })), - } - ] - }; - - case "copy": - let copyText = ""; - - state.blocks.forEach((block) => { - if (block.tag !== "") { - copyText += `[${block.tag}]\n`; - } - - block.lines.forEach((line) => { - if (line.text !== "") { - copyText += line.text + "\n"; - } - }) - copyText += "\n"; - }); - - navigator.clipboard.writeText(copyText); - return state; - - case "delete_block": - return { - ...state, - blocks: state.blocks.filter((block) => block.id !== action.blockId), - }; - - case "add_line": - return { - ...state, - blocks: state.blocks.map((block) => { - if (block.id === action.blockId) { - return { - ...block, - lines: [...block.lines, { id: uuidv4(), text: "" }], - }; - } else { - return block; - } - }), - }; - - case "delete_line": - return { - ...state, - blocks: state.blocks.map((block) => { - if (block.id === action.blockId && block.lines.length > 0) { - return { - ...block, - lines: block.lines.slice(0, -1), - }; - } else { - return block; - } - }), - }; - - case "update_line_text": - return { - ...state, - blocks: state.blocks.map((block) => { - if (block.id === action.blockId) { - return { - ...block, - lines: block.lines.map((line) => { - if (line.id === action.lineId) { - return { - ...line, - text: action.text, - }; - } else { - return line; - } - }), - }; - } else { - return block; - } - }), - }; - - case "update_tag": - return { - ...state, - blocks: state.blocks.map((block) => { - if (block.id === action.blockId) { - return { - ...block, - tag: action.tag, - }; - } else { - return block; - } - }), - }; - - case "move_block_up": { - const index = state.blocks.findIndex((b) => b.id === action.blockId); - if (index <= 0) return state; - const newBlocks = [...state.blocks]; - [newBlocks[index - 1], newBlocks[index]] = [newBlocks[index], newBlocks[index - 1]]; - return { - ...state, - blocks: newBlocks, - }; - } - - case "move_block_down": { - const index = state.blocks.findIndex((b) => b.id === action.blockId); - if (index === state.blocks.length - 1) return state; - const newBlocks = [...state.blocks]; - [newBlocks[index], newBlocks[index + 1]] = [newBlocks[index + 1], newBlocks[index]]; - return { - ...state, - blocks: newBlocks, - }; - } - - case "toggle_lock": - return { - ...state, - blocks: state.blocks.map((block) => { - if (block.id === action.blockId) { - return { - ...block, - locked: !block.locked, - }; - } else { - return block; - } - }), - }; - - default: - return state; - } -} diff --git a/src/lib/hooks/useDebounce.ts b/src/lib/hooks/useDebounce.ts new file mode 100644 index 0000000..db9fd2f --- /dev/null +++ b/src/lib/hooks/useDebounce.ts @@ -0,0 +1,9 @@ +import { useEffect } from "react"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function useDebounce(callback: () => void, deps: any[]) { + useEffect(() => { + const timeout = setTimeout(callback, 1000); + return () => clearTimeout(timeout); + }, deps); +}