feat: combine client and server note management

This commit is contained in:
Kirill Siukhin 2025-07-18 21:41:56 +05:00
parent bcf443c5e3
commit 1b8a9e1752
16 changed files with 581 additions and 275 deletions

View File

@ -0,0 +1,2 @@
ALTER TABLE "notes" RENAME COLUMN "creationTime" TO "createdAt";--> statement-breakpoint
ALTER TABLE "notes" RENAME COLUMN "lastEdited" TO "updatedAt";

View File

@ -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": {}
}
}

View File

@ -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
}
]
}

View File

@ -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<IBlock> {
return block;
}
export async function createBlock(formData: FormData) {
const noteId = formData.get("noteId") as string;
export async function createBlock(noteId: string): Promise<IBlock> {
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<IBlock[]> {
@ -71,16 +69,11 @@ export async function getBlocks(noteId: string): Promise<IBlock[]> {
.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);
});
}

View File

@ -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<INote> {
export async function getNoteIfOwned(noteId: string): Promise<INoteWithBlocks> {
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<INote[]> {
.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");
}

View File

@ -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",
};

View File

@ -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<Metadata> {
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 (
<div className="flex justify-center">
<Editor note={note} blocks={blocks} />
<Editor note={note} />
</div>
);
}

View File

@ -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 (
<div className="flex flex-col items-center gap-8">
<Editor />
<NoteEditor isUnauthorized />
<i className="text-center text-sm text-neutral-400">
Changes are not saved!<br />
<Link href="/auth" className="font-bold hover:underline">Log in</Link> to save your notes.

View File

@ -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<HTMLInputElement>) => {
const newLines = [...lines];
export default function Block({
block,
isUnauthorized = false,
dispatch,
}: {
block: IBlock;
isUnauthorized?: boolean;
dispatch: Dispatch<NoteAction>;
}) {
const lineChangeHandler = (e: ChangeEvent<HTMLInputElement>, 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 (
<div className="flex flex-col gap-2 w-full">
<div className="border-2 border-neutral-800 rounded-lg p-3 w-full">
@ -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)}
/>
))}
<div className="flex items-center mx-2 mt-2">
<div className="flex gap-1 mr-4">
<form action={addLine}>
<input type="hidden" name="blockId" value={block.id} />
<IconOnlyButton type="submit" icon={<Plus size={18} />} disabled={block.isLocked} />
</form>
<form action={deleteLine}>
<input type="hidden" name="blockId" value={block.id} />
<IconOnlyButton type="submit" icon={<Minus size={18} />} disabled={block.isLocked} />
</form>
<IconOnlyButton
onClick={() => dispatch({ type: "ADD_BLOCK_LINE", blockId: block.id })}
icon={<Plus size={18} />}
disabled={block.isLocked}
/>
<IconOnlyButton
onClick={() => dispatch({ type: "DELETE_BLOCK_LINE", blockId: block.id })}
icon={<Minus size={18} />}
disabled={block.isLocked}
/>
</div>
<div className="flex gap-1">
<form action={moveUp}>
<input type="hidden" name="blockId" value={block.id} />
<IconOnlyButton type="submit" icon={<ArrowUp size={18} />} />
</form>
<form action={moveDown}>
<input type="hidden" name="blockId" value={block.id} />
<IconOnlyButton type="submit" icon={<ArrowDown size={18} />} />
</form>
<IconOnlyButton
onClick={() => dispatch({ type: "MOVE_BLOCK_UP", blockId: block.id })}
icon={<ArrowUp size={18} />}
/>
<IconOnlyButton
onClick={() => dispatch({ type: "MOVE_BLOCK_DOWN", blockId: block.id })}
icon={<ArrowDown size={18} />}
/>
</div>
<div className="flex gap-2 ml-auto">
<form action={changeLock}>
<input type="hidden" name="blockId" value={block.id} />
{!block.isLocked && <input type="hidden" name="isLocked" value="true" />}
<IconOnlyButton
type="submit"
onClick={() => dispatch({ type: "CHANGE_BLOCK_LOCK", blockId: block.id })}
alwaysOn={block.isLocked}
icon={block.isLocked ? <Lock size={18} /> : <LockOpen size={18} />}
/>
</form>
<form action={deleteBlock}>
<input type="hidden" name="blockId" value={block.id} />
<IconOnlyButton type="submit" icon={<X size={18} />} />
</form>
<IconOnlyButton onClick={deletBlockHandler} icon={<X size={18} />} />
</div>
</div>
</div>

View File

@ -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 (
<div className="flex flex-col items-center max-w-2xl w-full gap-4">
<input
className="font-bold text-xl w-full text-center focus:outline-none"
onChange={(e) => setTitleState(e.target.value)}
value={title}
/>
{blocks.map((block) => <Block key={block.id} block={block} /> )}
<div className="flex gap-2">
<form action={createBlock}>
<input type="hidden" name="noteId" value={note.id} />
<IconOnlyButton icon={<Plus size={24} />} type="submit" />
</form>
<IconOnlyButton icon={<Copy size={24} />} onClick={copyHandler} title="Copy note to clipboard" />
</div>
</div>
);
}

View File

@ -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 (
<div className="flex flex-col items-center max-w-2xl w-full gap-4">
<input
className="font-bold text-xl w-full text-center focus:outline-none"
onChange={(e) => dispatch({ type: "UPDATE_TITLE", title: e.target.value })}
value={state.title}
/>
{sortedBlocks.map((block) => (
<Block
key={block.id}
block={block}
dispatch={dispatch}
isUnauthorized={isUnauthorized}
/>
))}
<div className="flex gap-2">
<IconOnlyButton onClick={createBlockHandler} icon={<Plus size={24} />} />
<IconOnlyButton
onClick={copyHandler}
icon={<Copy size={24} />}
title="Copy note to clipboard"
/>
</div>
</div>
);
}

View File

@ -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 }) {
<div className="flex items-center mb-3 gap-4">
<Link href={`/notes/${note.id}`} className="flex flex-col border border-neutral-500 py-3 px-4 rounded-lg hover:bg-neutral-800 w-full">
<h1 className="font-bold">{note.title}</h1>
<i className="text-neutral-400 text-sm">Last time edited: {makeTimestamp(note.lastEdited)}</i>
<i className="text-neutral-400 text-sm">Creation date: {makeTimestamp(note.creationTime)}</i>
<i className="text-neutral-400 text-sm">Last time edited: {makeTimestamp(note.updatedAt)}</i>
<i className="text-neutral-400 text-sm">Creation date: {makeTimestamp(note.createdAt)}</i>
</Link>
<form action={deleteNote}>
<input type="hidden" name="noteId" value={note.id} />

View File

@ -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;

9
src/lib/db/types.ts Normal file
View File

@ -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[];
}

View File

@ -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);

View File

@ -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;
}
}