feat: combine client and server note management
This commit is contained in:
parent
bcf443c5e3
commit
1b8a9e1752
2
drizzle/0002_tired_ghost_rider.sql
Normal file
2
drizzle/0002_tired_ghost_rider.sql
Normal file
@ -0,0 +1,2 @@
|
||||
ALTER TABLE "notes" RENAME COLUMN "creationTime" TO "createdAt";--> statement-breakpoint
|
||||
ALTER TABLE "notes" RENAME COLUMN "lastEdited" TO "updatedAt";
|
187
drizzle/meta/0002_snapshot.json
Normal file
187
drizzle/meta/0002_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
@ -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");
|
||||
}
|
||||
|
@ -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",
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
95
src/components/editor/NoteEditor.tsx
Normal file
95
src/components/editor/NoteEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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} />
|
||||
|
@ -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
9
src/lib/db/types.ts
Normal 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[];
|
||||
}
|
@ -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);
|
||||
|
186
src/lib/reducers/noteReducer.ts
Normal file
186
src/lib/reducers/noteReducer.ts
Normal 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;
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user