From 1b8a9e17523085a125be3a86dd2f32a097a1fbe8 Mon Sep 17 00:00:00 2001 From: misterkirill Date: Fri, 18 Jul 2025 21:41:56 +0500 Subject: [PATCH] feat: combine client and server note management --- drizzle/0002_tired_ghost_rider.sql | 2 + drizzle/meta/0002_snapshot.json | 187 +++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/app/actions/blocks.ts | 142 +++----------------- src/app/actions/notes.ts | 17 ++- src/app/auth/page.tsx | 2 +- src/app/notes/[id]/page.tsx | 6 +- src/app/page.tsx | 4 +- src/components/editor/Block.tsx | 106 ++++++++------- src/components/editor/Editor.tsx | 75 ----------- src/components/editor/NoteEditor.tsx | 95 ++++++++++++++ src/components/ui/NoteCard.tsx | 6 +- src/lib/db/schema.ts | 10 +- src/lib/db/types.ts | 9 ++ src/lib/hooks/useDebounce.ts | 2 +- src/lib/reducers/noteReducer.ts | 186 ++++++++++++++++++++++++++ 16 files changed, 581 insertions(+), 275 deletions(-) create mode 100644 drizzle/0002_tired_ghost_rider.sql create mode 100644 drizzle/meta/0002_snapshot.json delete mode 100644 src/components/editor/Editor.tsx create mode 100644 src/components/editor/NoteEditor.tsx create mode 100644 src/lib/db/types.ts create mode 100644 src/lib/reducers/noteReducer.ts diff --git a/drizzle/0002_tired_ghost_rider.sql b/drizzle/0002_tired_ghost_rider.sql new file mode 100644 index 0000000..8ef15ef --- /dev/null +++ b/drizzle/0002_tired_ghost_rider.sql @@ -0,0 +1,2 @@ +ALTER TABLE "notes" RENAME COLUMN "creationTime" TO "createdAt";--> statement-breakpoint +ALTER TABLE "notes" RENAME COLUMN "lastEdited" TO "updatedAt"; \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..641750b --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,187 @@ +{ + "id": "87053763-7633-4589-a8fb-ad1b9f822314", + "prevId": "b75921d7-7437-4ff8-980f-8450655b4b64", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.blocks": { + "name": "blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tag": { + "name": "tag", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "lines": { + "name": "lines", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'[\"\",\"\",\"\",\"\"]'::json" + }, + "isLocked": { + "name": "isLocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "noteId": { + "name": "noteId", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "blocks_noteId_notes_id_fk": { + "name": "blocks_noteId_notes_id_fk", + "tableFrom": "blocks", + "tableTo": "notes", + "columnsFrom": [ + "noteId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notes": { + "name": "notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'Untitled'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "authorId": { + "name": "authorId", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "notes_authorId_users_id_fk": { + "name": "notes_authorId_users_id_fk", + "tableFrom": "notes", + "tableTo": "users", + "columnsFrom": [ + "authorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 895a00b..c356b28 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1752591486103, "tag": "0001_complex_dreadnoughts", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1752847652742, + "tag": "0002_tired_ghost_rider", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/actions/blocks.ts b/src/app/actions/blocks.ts index 336a280..17e140b 100644 --- a/src/app/actions/blocks.ts +++ b/src/app/actions/blocks.ts @@ -1,10 +1,10 @@ "use server"; -import { revalidatePath } from "next/cache"; import { notFound } from "next/navigation"; import { eq } from "drizzle-orm"; import { db } from "@/lib/db"; -import { blocksTable, IBlock, notesTable } from "@/lib/db/schema"; +import { blocksTable, notesTable } from "@/lib/db/schema"; +import { IBlock } from "@/lib/db/types"; import { requireAuth } from "./auth"; import { assertNoteOwner } from "./notes"; @@ -23,7 +23,7 @@ export async function assertBlockOwner(blockId: string, authorId: string) { async function updateLastEdited(tx: Transaction, noteId: string) { await tx .update(notesTable) - .set({ lastEdited: new Date() }) + .set({ updatedAt: new Date() }) .where(eq(notesTable.id, noteId)); } @@ -40,9 +40,7 @@ export async function getBlockIfOwned(blockId: string): Promise { return block; } -export async function createBlock(formData: FormData) { - const noteId = formData.get("noteId") as string; - +export async function createBlock(noteId: string): Promise { const user = await requireAuth(); await assertNoteOwner(noteId, user.id); @@ -50,14 +48,14 @@ export async function createBlock(formData: FormData) { const lastBlock = blocks.pop(); const order = lastBlock === undefined ? 1 : lastBlock.order + 1; - await db.transaction(async (tx) => { - await tx + return db.transaction(async (tx) => { + const [newBlock] = await tx .insert(blocksTable) - .values({ noteId, order }); + .values({ noteId, order }) + .returning(); await updateLastEdited(tx, noteId); + return newBlock; }); - - revalidatePath("/notes/[id]", "page"); } export async function getBlocks(noteId: string): Promise { @@ -71,16 +69,11 @@ export async function getBlocks(noteId: string): Promise { .orderBy(blocksTable.order); } -export async function deleteBlock(formData: FormData) { - const blockId = formData.get("blockId") as string; - +export async function deleteBlock(blockId: string) { const user = await requireAuth(); await assertBlockOwner(blockId, user.id); const block = await getBlockIfOwned(blockId); - if (!block) { - return; - }; await db.transaction(async (tx) => { await tx @@ -88,75 +81,6 @@ export async function deleteBlock(formData: FormData) { .where(eq(blocksTable.id, blockId)); await updateLastEdited(tx, block.noteId); }); - - revalidatePath("/notes/[id]", "page"); -} - -export async function changeLock(formData: FormData) { - const blockId = formData.get("blockId") as string; - const isLocked = formData.get("isLocked") === "true"; - - const user = await requireAuth(); - await assertBlockOwner(blockId, user.id); - - const block = await getBlockIfOwned(blockId); - if (!block) { - return - } - - await db.transaction(async (tx) => { - await tx - .update(blocksTable) - .set({ isLocked }) - .where(eq(blocksTable.id, blockId)); - await updateLastEdited(tx, block.noteId); - }); - - revalidatePath("/notes/[id]", "page"); -} - -export async function addLine(formData: FormData) { - const blockId = formData.get("blockId") as string; - - const user = await requireAuth(); - await assertBlockOwner(blockId, user.id); - - const block = await getBlockIfOwned(blockId); - if (!block) { - return; - } - - await db.transaction(async (tx) => { - await tx - .update(blocksTable) - .set({ lines: [...block.lines, ""] }) - .where(eq(blocksTable.id, blockId)); - await updateLastEdited(tx, block.noteId); - }); - - revalidatePath("/notes/[id]", "page"); -} - -export async function deleteLine(formData: FormData) { - const blockId = formData.get("blockId") as string; - - const user = await requireAuth(); - await assertBlockOwner(blockId, user.id); - - const block = await getBlockIfOwned(blockId); - if (!block) { - return; - } - - await db.transaction(async (tx) => { - await tx - .update(blocksTable) - .set({ lines: block.lines.slice(0, -1) }) - .where(eq(blocksTable.id, blockId)); - await updateLastEdited(tx, block.noteId); - }); - - revalidatePath("/notes/[id]", "page"); } export async function moveUp(formData: FormData) { @@ -166,9 +90,6 @@ export async function moveUp(formData: FormData) { await assertBlockOwner(blockId, user.id); const block = await getBlockIfOwned(blockId); - if (!block) { - return; - } const siblings = await getBlocks(block.noteId); const currentIndex = siblings.findIndex((b) => b.id === block.id); @@ -192,8 +113,6 @@ export async function moveUp(formData: FormData) { .where(eq(blocksTable.id, prevBlock.id)); await updateLastEdited(tx, block.noteId); }); - - revalidatePath("/notes/[id]", "page"); } export async function moveDown(formData: FormData) { @@ -203,9 +122,6 @@ export async function moveDown(formData: FormData) { await assertBlockOwner(blockId, user.id); const block = await getBlockIfOwned(blockId); - if (!block) { - return; - } const siblings = await getBlocks(block.noteId); const currentIndex = siblings.findIndex((b) => b.id === block.id); @@ -229,42 +145,22 @@ export async function moveDown(formData: FormData) { .where(eq(blocksTable.id, nextBlock.id)); await updateLastEdited(tx, block.noteId); }); - - revalidatePath("/notes/[id]", "page"); } -export async function setLines(blockId: string, lines: string[]) { +export async function updateBlock(block: IBlock) { const user = await requireAuth(); - await assertBlockOwner(blockId, user.id); - - const block = await getBlockIfOwned(blockId); - if (!block) { - return; - } + await assertBlockOwner(block.id, user.id); await db.transaction(async (tx) => { await tx .update(blocksTable) - .set({ lines }) - .where(eq(blocksTable.id, blockId)); - await updateLastEdited(tx, block.noteId); - }); -} - -export async function setTag(blockId: string, tag: string) { - const user = await requireAuth(); - await assertBlockOwner(blockId, user.id); - - const block = await getBlockIfOwned(blockId); - if (!block) { - return; - } - - await db.transaction(async (tx) => { - await tx - .update(blocksTable) - .set({ tag }) - .where(eq(blocksTable.id, blockId)); + .set({ + tag: block.tag, + lines: block.lines, + isLocked: block.isLocked, + order: block.order, + }) + .where(eq(blocksTable.id, block.id)); await updateLastEdited(tx, block.noteId); }); } diff --git a/src/app/actions/notes.ts b/src/app/actions/notes.ts index af176c9..fbe2a68 100644 --- a/src/app/actions/notes.ts +++ b/src/app/actions/notes.ts @@ -1,11 +1,12 @@ "use server"; -import { revalidatePath } from "next/cache"; import { notFound, redirect } from "next/navigation"; +import { revalidatePath } from "next/cache"; import { desc, eq, and } from "drizzle-orm"; -import { blocksTable, INote, notesTable, usersTable } from "@/lib/db/schema"; -import { requireAuth } from "./auth"; +import { blocksTable, notesTable, usersTable } from "@/lib/db/schema"; +import { INote, INoteWithBlocks } from "@/lib/db/types"; import { db } from "@/lib/db"; +import { requireAuth } from "./auth"; export async function assertNoteOwner(noteId: string, userId: string) { const note = await db.query.notesTable.findFirst({ @@ -16,11 +17,12 @@ export async function assertNoteOwner(noteId: string, userId: string) { } } -export async function getNoteIfOwned(noteId: string): Promise { +export async function getNoteIfOwned(noteId: string): Promise { const user = await requireAuth(); const note = await db.query.notesTable.findFirst({ where: eq(notesTable.id, noteId), + with: { blocks: { orderBy: blocksTable.order } }, }); if (!note || note.authorId !== user.id) { notFound(); @@ -46,7 +48,7 @@ export async function getNotes(): Promise { .select() .from(notesTable) .where(eq(notesTable.authorId, user.id)) - .orderBy(desc(notesTable.lastEdited)); + .orderBy(desc(notesTable.updatedAt)); } export async function deleteNote(formData: FormData) { @@ -58,7 +60,6 @@ export async function deleteNote(formData: FormData) { await db .delete(notesTable) .where(and(eq(notesTable.id, noteId), eq(notesTable.authorId, user.id))); - revalidatePath("/notes"); } @@ -72,8 +73,6 @@ export async function setTitle(noteId: string, title: string) { await db .update(notesTable) - .set({ title, lastEdited: new Date() }) + .set({ title, updatedAt: new Date() }) .where(eq(notesTable.id, noteId)); - - revalidatePath("/notes/[id]", "page"); } diff --git a/src/app/auth/page.tsx b/src/app/auth/page.tsx index d59522e..bd81afb 100644 --- a/src/app/auth/page.tsx +++ b/src/app/auth/page.tsx @@ -2,7 +2,7 @@ import { Metadata } from "next"; import AuthForm from "@/components/forms/AuthForm"; export const metadata: Metadata = { - title: "Authentication - Rhyme", + title: "Authenticate - Rhyme", description: "Register or log into Rhyme account to save, show and load notes", }; diff --git a/src/app/notes/[id]/page.tsx b/src/app/notes/[id]/page.tsx index 0cecfec..18137ec 100644 --- a/src/app/notes/[id]/page.tsx +++ b/src/app/notes/[id]/page.tsx @@ -1,7 +1,6 @@ import { Metadata } from "next"; import { getNoteIfOwned } from "@/app/actions/notes"; -import { getBlocks } from "@/app/actions/blocks"; -import Editor from "@/components/editor/Editor"; +import Editor from "@/components/editor/NoteEditor"; export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise { const { id } = await params; @@ -12,11 +11,10 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin export default async function Note({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const note = await getNoteIfOwned(id); - const blocks = await getBlocks(note.id); return (
- +
); } diff --git a/src/app/page.tsx b/src/app/page.tsx index d937aca..78d828e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,7 +1,7 @@ import Link from "next/link"; import { redirect } from "next/navigation"; import { getAuth } from "@/app/actions/auth"; -import Editor from "@/components/editor/Editor"; +import NoteEditor from "@/components/editor/NoteEditor"; export default async function Home() { const auth = await getAuth(); @@ -11,7 +11,7 @@ export default async function Home() { return (
- + Changes are not saved!
Log in to save your notes. diff --git a/src/components/editor/Block.tsx b/src/components/editor/Block.tsx index 7c649ad..3eb33a7 100644 --- a/src/components/editor/Block.tsx +++ b/src/components/editor/Block.tsx @@ -1,29 +1,40 @@ -import { ChangeEvent, useState } from "react"; +import { ChangeEvent, Dispatch } from "react"; import { ArrowDown, ArrowUp, LockOpen, Lock, Minus, Plus, X } from "lucide-react"; -import { addLine, changeLock, deleteBlock, deleteLine, moveDown, moveUp, setLines, setTag } from "@/app/actions/blocks"; +import { deleteBlock, updateBlock } from "@/app/actions/blocks"; +import { NoteAction } from "@/lib/reducers/noteReducer"; import { useDebounce } from "@/lib/hooks/useDebounce"; -import { IBlock } from "@/lib/db/schema"; +import { IBlock } from "@/lib/db/types"; import IconOnlyButton from "../ui/IconOnlyButton"; import LineInput from "./LineInput"; -export default function Block({ block }: { block: IBlock }) { - const [lines, setLinesState] = useState(block.lines); - const [tag, setTagState] = useState(block.tag); - - useDebounce(() => { - setLines(block.id, lines); - }, [lines]); - - useDebounce(() => { - setTag(block.id, tag); - }, [tag]); - - const lineChangeHandler = (i: number, e: ChangeEvent) => { - const newLines = [...lines]; +export default function Block({ + block, + isUnauthorized = false, + dispatch, +}: { + block: IBlock; + isUnauthorized?: boolean; + dispatch: Dispatch; +}) { + const lineChangeHandler = (e: ChangeEvent, i: number) => { + const newLines = [...block.lines]; newLines[i] = e.target.value; - setLinesState(newLines); + dispatch({ type: "UPDATE_BLOCK_LINES", blockId: block.id, lines: newLines }); } + const deletBlockHandler = () => { + dispatch({ type: "DELETE_BLOCK", blockId: block.id }); + if (!isUnauthorized) { + deleteBlock(block.id); + } + } + + useDebounce(() => { + if (!isUnauthorized) { + updateBlock(block); + } + }, [block]); + return (
@@ -31,8 +42,8 @@ export default function Block({ block }: { block: IBlock }) { type="text" placeholder="enter tag..." className="w-full focus:outline-none" - onChange={(e) => setTagState(e.target.value)} - value={tag} + onChange={(e) => dispatch({ type: "UPDATE_BLOCK_TAG", blockId: block.id, tag: e.target.value })} + value={block.tag} disabled={block.isLocked} /> {block.lines.map((line, i) => ( @@ -40,44 +51,39 @@ export default function Block({ block }: { block: IBlock }) { key={i} defaultValue={line} disabled={block.isLocked} - onChange={(e) => lineChangeHandler(i, e)} + onChange={(e) => lineChangeHandler(e, i)} /> ))}
-
- - } disabled={block.isLocked} /> - -
- - } disabled={block.isLocked} /> - + dispatch({ type: "ADD_BLOCK_LINE", blockId: block.id })} + icon={} + disabled={block.isLocked} + /> + dispatch({ type: "DELETE_BLOCK_LINE", blockId: block.id })} + icon={} + disabled={block.isLocked} + />
-
- - } /> - -
- - } /> - + dispatch({ type: "MOVE_BLOCK_UP", blockId: block.id })} + icon={} + /> + dispatch({ type: "MOVE_BLOCK_DOWN", blockId: block.id })} + icon={} + />
-
- - {!block.isLocked && } - : } - /> - -
- - } /> - + dispatch({ type: "CHANGE_BLOCK_LOCK", blockId: block.id })} + alwaysOn={block.isLocked} + icon={block.isLocked ? : } + /> + } />
diff --git a/src/components/editor/Editor.tsx b/src/components/editor/Editor.tsx deleted file mode 100644 index e50cda1..0000000 --- a/src/components/editor/Editor.tsx +++ /dev/null @@ -1,75 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { Copy, Plus } from "lucide-react"; -import { v4 as uuidv4 } from "uuid"; -import { IBlock, INote } from "@/lib/db/schema"; -import { createBlock } from "@/app/actions/blocks"; -import { setTitle } from "@/app/actions/notes"; -import { useDebounce } from "@/lib/hooks/useDebounce"; -import IconOnlyButton from "../ui/IconOnlyButton"; -import Block from "./Block"; - -const defaultNoteId = uuidv4(); - -const defaultNote: INote = { - id: defaultNoteId, - title: "Untitled", - creationTime: new Date(), - lastEdited: new Date(), - authorId: uuidv4(), -}; - -const defaultBlocks: IBlock[] = [ - { - id: uuidv4(), - tag: "", - lines: ["", "", "", ""], - isLocked: false, - order: 1, - noteId: defaultNoteId, - }, -]; - -export default function Editor({ - note = defaultNote, - blocks = defaultBlocks, -}: { - note?: INote; - blocks?: IBlock[]; -}) { - const [title, setTitleState] = useState(note.title); - - const copyHandler = () => { - let copyText = ""; - blocks.forEach((block) => { - if (block.tag !== "") { - copyText += `[${block.tag}]`; - } - copyText += block.lines.join("\n"); - }); - navigator.clipboard.writeText(copyText); - } - - useDebounce(() => { - setTitle(note.id, title); - }, [title]); - - return ( -
- setTitleState(e.target.value)} - value={title} - /> - {blocks.map((block) => )} -
-
- - } type="submit" /> - - } onClick={copyHandler} title="Copy note to clipboard" /> -
-
- ); -} diff --git a/src/components/editor/NoteEditor.tsx b/src/components/editor/NoteEditor.tsx new file mode 100644 index 0000000..de4b3e6 --- /dev/null +++ b/src/components/editor/NoteEditor.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useReducer } from "react"; +import { Copy, Plus } from "lucide-react"; +import { v4 as uuidv4 } from "uuid"; +import { createBlock } from "@/app/actions/blocks"; +import { setTitle } from "@/app/actions/notes"; +import { useDebounce } from "@/lib/hooks/useDebounce"; +import { noteReducer } from "@/lib/reducers/noteReducer"; +import { INoteWithBlocks } from "@/lib/db/types"; +import IconOnlyButton from "../ui/IconOnlyButton"; +import Block from "./Block"; + +const defaultNote: INoteWithBlocks = { + id: "", + title: "Untitled", + authorId: "", + createdAt: new Date(), + updatedAt: new Date(), + blocks: [ + { + id: "", + noteId: "", + tag: "", + lines: ["", "", "", ""], + isLocked: false, + order: 1, + }, + ], +}; + +export default function NoteEditor({ + note = defaultNote, + isUnauthorized = false, +}: { + note?: INoteWithBlocks; + isUnauthorized?: boolean; +}) { + const [state, dispatch] = useReducer(noteReducer, note); + + const copyHandler = () => { + let copyText = ""; + state.blocks.forEach((block) => { + if (block.tag !== "") { + copyText += `[${block.tag}]\n`; + } + copyText += block.lines.join("\n") + "\n\n"; + }); + navigator.clipboard.writeText(copyText); + } + + const createBlockHandler = () => { + const tempBlockId = uuidv4(); + dispatch({ type: "CREATE_BLOCK", noteId: note.id, blockId: tempBlockId }); + if (!isUnauthorized) { + createBlock(note.id).then((block) => { + dispatch({ type: "CHANGE_BLOCK_ID", blockId: tempBlockId, newBlockId: block.id }); + }); + } + } + + useDebounce(() => { + if (!isUnauthorized) { + setTitle(note.id, state.title); + } + }, [state.title]); + + const sortedBlocks = state.blocks.sort((a, b) => a.order - b.order); + + return ( +
+ dispatch({ type: "UPDATE_TITLE", title: e.target.value })} + value={state.title} + /> + {sortedBlocks.map((block) => ( + + ))} +
+ } /> + } + title="Copy note to clipboard" + /> +
+
+ ); +} diff --git a/src/components/ui/NoteCard.tsx b/src/components/ui/NoteCard.tsx index 0a0fa47..b735870 100644 --- a/src/components/ui/NoteCard.tsx +++ b/src/components/ui/NoteCard.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; import { X } from "lucide-react"; -import { INote } from "@/lib/db/schema"; +import { INote } from "@/lib/db/types"; import { deleteNote } from "@/app/actions/notes"; import IconOnlyButton from "./IconOnlyButton"; @@ -18,8 +18,8 @@ export default function NoteCard({ note }: { note: INote }) {

{note.title}

- Last time edited: {makeTimestamp(note.lastEdited)} - Creation date: {makeTimestamp(note.creationTime)} + Last time edited: {makeTimestamp(note.updatedAt)} + Creation date: {makeTimestamp(note.createdAt)}
diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 669b7cc..fa4c480 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -14,8 +14,8 @@ export const usersRelations = relations(usersTable, ({ many }) => ({ export const notesTable = pgTable("notes", { id: uuid().primaryKey().defaultRandom(), title: varchar({ length: 50 }).notNull().default("Untitled"), - creationTime: timestamp().notNull().defaultNow(), - lastEdited: timestamp().notNull().defaultNow(), + createdAt: timestamp().notNull().defaultNow(), + updatedAt: timestamp().notNull().defaultNow(), authorId: uuid().notNull().references(() => usersTable.id, { onDelete: "cascade" }), }); @@ -24,7 +24,7 @@ export const notesRelations = relations(notesTable, ({ one, many }) => ({ fields: [notesTable.authorId], references: [usersTable.id], }), - block: many(blocksTable), + blocks: many(blocksTable), })); export const blocksTable = pgTable("blocks", { @@ -46,7 +46,3 @@ export const blocksRelations = relations(blocksTable, ({ one }) => ({ references: [blocksTable.id], }), })); - -export type IUser = typeof usersTable.$inferSelect; -export type INote = typeof notesTable.$inferSelect; -export type IBlock = typeof blocksTable.$inferSelect; diff --git a/src/lib/db/types.ts b/src/lib/db/types.ts new file mode 100644 index 0000000..248ce4f --- /dev/null +++ b/src/lib/db/types.ts @@ -0,0 +1,9 @@ +import { blocksTable, notesTable, usersTable } from "./schema"; + +export type IUser = typeof usersTable.$inferSelect; +export type INote = typeof notesTable.$inferSelect; +export type IBlock = typeof blocksTable.$inferSelect; + +export interface INoteWithBlocks extends INote { + blocks: IBlock[]; +} diff --git a/src/lib/hooks/useDebounce.ts b/src/lib/hooks/useDebounce.ts index bf9d12c..d569131 100644 --- a/src/lib/hooks/useDebounce.ts +++ b/src/lib/hooks/useDebounce.ts @@ -8,7 +8,7 @@ export function useDebounce(callback: () => void, deps: DependencyList) { if (isFirstRun.current) { isFirstRun.current = false; } else { - const timeout = setTimeout(callback, 1000); + const timeout = setTimeout(callback, 400); return () => clearTimeout(timeout); } }, deps); diff --git a/src/lib/reducers/noteReducer.ts b/src/lib/reducers/noteReducer.ts new file mode 100644 index 0000000..ab4d43e --- /dev/null +++ b/src/lib/reducers/noteReducer.ts @@ -0,0 +1,186 @@ +import { INoteWithBlocks } from "@/lib/db/types"; + +export type NoteAction = + | { type: "UPDATE_TITLE"; title: string } + | { type: "CREATE_BLOCK", noteId: string, blockId: string } + | { type: "DELETE_BLOCK"; blockId: string } + | { type: "ADD_BLOCK_LINE"; blockId: string } + | { type: "DELETE_BLOCK_LINE"; blockId: string } + | { type: "UPDATE_BLOCK_TAG"; blockId: string, tag: string } + | { type: "UPDATE_BLOCK_LINES"; blockId: string, lines: string[] } + | { type: "MOVE_BLOCK_UP"; blockId: string } + | { type: "MOVE_BLOCK_DOWN"; blockId: string } + | { type: "CHANGE_BLOCK_LOCK"; blockId: string } + | { type: "CHANGE_BLOCK_ID"; blockId: string, newBlockId: string }; + +export function noteReducer(state: INoteWithBlocks, action: NoteAction): INoteWithBlocks { + switch (action.type) { + case "UPDATE_TITLE": + document.title = action.title; + return { ...state, title: action.title }; + + case "CREATE_BLOCK": + const maxOrder = Math.max(0, ...state.blocks.map(b => b.order)); + return { + ...state, + blocks: [ + ...state.blocks, + { + id: action.blockId, + tag: "", + lines: ["", "", "", ""], + order: maxOrder + 1, + isLocked: false, + noteId: action.noteId, + }, + ], + }; + + case "CHANGE_BLOCK_ID": + return { + ...state, + blocks: state.blocks.map((b) => { + if (b.id === action.blockId) { + return { + ...b, + id: action.newBlockId, + }; + } else { + return b; + } + }), + }; + + case "UPDATE_BLOCK_TAG": + return { + ...state, + blocks: state.blocks.map((b) => { + if (b.id === action.blockId) { + return { + ...b, + tag: action.tag, + }; + } else { + return b; + } + }), + }; + + case "UPDATE_BLOCK_LINES": + return { + ...state, + blocks: state.blocks.map((b) => { + if (b.id === action.blockId) { + return { + ...b, + lines: action.lines, + }; + } else { + return b; + } + }), + }; + + case "DELETE_BLOCK": + return { + ...state, + blocks: state.blocks.filter((b) => b.id !== action.blockId), + }; + + case "MOVE_BLOCK_UP": { + const sortedBlocks = state.blocks.sort((a, b) => a.order - b.order); + const blockIndex = sortedBlocks.findIndex((b) => b.id === action.blockId); + const prevBlockIndex = blockIndex - 1; + if (prevBlockIndex < 0) { + return state; + } + + const newBlocks = [...sortedBlocks]; + newBlocks[blockIndex] = { + ...newBlocks[blockIndex], + order: newBlocks[prevBlockIndex].order, + }; + newBlocks[prevBlockIndex] = { + ...newBlocks[prevBlockIndex], + order: newBlocks[blockIndex].order + 1, + }; + + return { + ...state, + blocks: newBlocks, + }; + } + + case "MOVE_BLOCK_DOWN": { + const sortedBlocks = state.blocks.sort((a, b) => a.order - b.order); + const blockIndex = sortedBlocks.findIndex((b) => b.id === action.blockId); + const nextBlockIndex = blockIndex + 1; + if (nextBlockIndex >= sortedBlocks.length) { + return state; + } + + const newBlocks = [...sortedBlocks]; + newBlocks[blockIndex] = { + ...newBlocks[blockIndex], + order: newBlocks[nextBlockIndex].order, + }; + newBlocks[nextBlockIndex] = { + ...newBlocks[nextBlockIndex], + order: newBlocks[blockIndex].order - 1, + }; + + return { + ...state, + blocks: newBlocks, + }; + } + + case "ADD_BLOCK_LINE": + return { + ...state, + blocks: state.blocks.map((b) => { + if (b.id === action.blockId) { + return { + ...b, + lines: [...b.lines, ""], + }; + } else { + return b; + } + }), + }; + + case "DELETE_BLOCK_LINE": + return { + ...state, + blocks: state.blocks.map((b) => { + if (b.id === action.blockId) { + return { + ...b, + lines: b.lines.slice(0, -1), + }; + } else { + return b; + } + }), + }; + + case "CHANGE_BLOCK_LOCK": + return { + ...state, + blocks: state.blocks.map((b) => { + if (b.id === action.blockId) { + return { + ...b, + isLocked: !b.isLocked, + }; + } else { + return b; + } + }), + }; + + default: + return state; + } +}