feat: start editor database integration

This commit is contained in:
Kirill Siukhin 2025-07-11 17:27:28 +05:00
parent 2ad220e525
commit 917e460a96
13 changed files with 175 additions and 320 deletions

View File

@ -1,23 +1,42 @@
"use server";
import { eq } from "drizzle-orm";
import { blocksTable } from "@/lib/db/schema";
import { revalidatePath } from "next/cache";
import { eq, and } from "drizzle-orm";
import { blocksTable, IBlock, INote } from "@/lib/db/schema";
import { db } from "@/lib/db";
export async function updateBlock({
id,
tag,
lines,
}: {
id: string;
tag?: string;
lines?: string[];
}) {
export async function createBlock(note: INote) {
await db
.insert(blocksTable)
.values({ noteId: note.id });
revalidatePath("/blocks/[id]");
}
export async function getBlocks(note: INote) {
return db.select()
.from(blocksTable)
.where(and(eq(blocksTable.noteId, note.id)));
}
export async function updateBlockTag(block: IBlock, newTag: string) {
await db
.update(blocksTable)
.set({
...(tag !== undefined ? { tag } : {}),
...(lines !== undefined ? { lines } : {}),
})
.where(eq(blocksTable.id, id));
.set({ tag: newTag })
.where(eq(blocksTable.id, block.id));
}
export async function deleteBlock(block: IBlock) {
await db.delete(blocksTable)
.where(and(eq(blocksTable.id, block.id)));
revalidatePath("/blocks/[id]");
}
export async function switchLock(block: IBlock) {
await db.update(blocksTable)
.set({ isLocked: !block.isLocked })
.where(and(eq(blocksTable.id, block.id)));
revalidatePath("/blocks/[id]");
}

View File

@ -1,8 +1,26 @@
import { desc, eq, and } from "drizzle-orm";
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { validate as uuidValidate } from "uuid";
import { notesTable } from "./db/schema";
import { getAuth } from "./auth";
import { db } from "./db";
import { notesTable } from "@/lib/db/schema";
import { getAuth } from "@/lib/auth";
import { eq, and, desc } from "drizzle-orm";
import { db } from "@/lib/db";
export async function createNote() {
const auth = await getAuth();
if (!auth) {
redirect("/auth");
}
const notes = await db
.insert(notesTable)
.values({ authorId: auth.id })
.returning({ id: notesTable.id });
redirect(`/notes/${notes[0].id}`);
}
export async function getNotes(authorId: string) {
return db.select()
@ -32,20 +50,6 @@ export async function getNote(noteId: string) {
}
}
export async function createNote() {
const auth = await getAuth();
if (!auth) {
return null;
}
const note = await db
.insert(notesTable)
.values({ authorId: auth.id })
.returning({ id: notesTable.id });
return note[0].id;
}
export async function deleteNote(noteId: string) {
if (!uuidValidate(noteId)) {
return null;
@ -57,4 +61,12 @@ export async function deleteNote(noteId: string) {
}
await db.delete(notesTable).where(eq(notesTable.id, noteId));
revalidatePath("/notes");
}
export async function updateTitle(noteId: string, title: string) {
await db
.update(notesTable)
.set({ title })
.where(eq(notesTable.id, noteId));
}

8
src/app/not-found.tsx Normal file
View File

@ -0,0 +1,8 @@
export default function NotFound() {
return (
<div className="text-center">
<h1 className="font-bold text-2xl mb-2">404</h1>
<p>Page not found</p>
</div>
);
}

View File

@ -1,6 +1,7 @@
import { Metadata } from "next";
import { notFound } from "next/navigation";
import { getNote } from "@/lib/notes";
import { getBlocks } from "@/app/actions/blocks";
import { getNote } from "@/app/actions/notes";
import Editor from "@/components/editor/Editor";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
@ -10,7 +11,7 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
notFound();
}
return { title: note.name };
return { title: note.title };
}
export default async function Note({ params }: { params: Promise<{ id: string }> }) {
@ -19,10 +20,15 @@ export default async function Note({ params }: { params: Promise<{ id: string }>
if (!note) {
notFound();
}
const blocks = await getBlocks(note);
if (!blocks) {
notFound();
}
return (
<div className="flex justify-center">
<Editor defaultTitle={note.name} />
<Editor note={note} blocks={blocks} />
</div>
);
}

View File

@ -1,17 +0,0 @@
import { redirect } from "next/navigation";
import { createNote } from "@/lib/notes";
export default async function NewNote() {
const noteId = await createNote();
if (noteId) {
redirect(`/notes/${noteId}`);
}
return (
<div className="text-center mt-4">
Failed to create a new note!<br />
Please, try again.
</div>
);
}

View File

@ -1,6 +1,6 @@
import { Metadata } from "next";
import { redirect } from "next/navigation";
import { getNotes } from "@/lib/notes";
import { getNotes } from "@/app/actions/notes";
import { getAuth } from "@/lib/auth";
import NoteCard from "@/components/ui/NoteCard";

View File

@ -1,5 +1,6 @@
import Link from "next/link";
import { CircleQuestionMark, List, Plus, UserRound, UserRoundMinus } from "lucide-react";
import { createNote } from "@/app/actions/notes";
import { logOut } from "@/app/actions/auth";
import { getAuth } from "@/lib/auth";
import HeaderButton from "./ui/HeaderButton";
@ -15,9 +16,7 @@ export default async function Header() {
{auth && (
<div className="flex gap-2">
<Link href="/notes/new">
<HeaderButton title="new" icon={<Plus size={20} />} />
</Link>
<HeaderButton onClick={createNote} title="new" icon={<Plus size={20} />} />
<Link href="/notes">
<HeaderButton title="list" icon={<List size={20} />} />
</Link>

View File

@ -2,47 +2,34 @@
import { ChangeEvent } from "react";
import { ArrowDown, ArrowUp, Lock, LockOpen, Minus, Plus, X } from "lucide-react";
import { Action, IBlock, ILine } from "@/lib/editorReducer";
import { IBlock } from "@/lib/db/schema";
import { switchLock, deleteBlock } from "@/app/actions/blocks";
import IconOnlyButton from "../ui/IconOnlyButton";
import LineInput from "./LineInput";
export default function Block({
block,
dispatch,
}: {
block: IBlock;
dispatch: React.Dispatch<Action>;
}) {
export default function Block({ block }: { block: IBlock }) {
const handleAddLine = () => {
dispatch({ type: "add_line", blockId: block.id });
// TODO
}
const handleDeleteLine = () => {
dispatch({ type: "delete_line", blockId: block.id });
// TODO
}
const handleTagUpdate = (e: ChangeEvent<HTMLInputElement>) => {
dispatch({ type: "update_tag", blockId: block.id, tag: e.target.value });
// TODO
}
const handleLineUpdate = (e: ChangeEvent<HTMLInputElement>, line: ILine) => {
dispatch({ type: "update_line_text", blockId: block.id, lineId: line.id, text: e.target.value });
}
const handleDeleteBlock = () => {
dispatch({ type: "delete_block", blockId: block.id });
const handleLineUpdate = (e: ChangeEvent<HTMLInputElement>, line: string) => {
// TODO
}
const handleBlockUp = () => {
dispatch({ type: "move_block_up", blockId: block.id });
// TODO
}
const handleBlockDown = () => {
dispatch({ type: "move_block_down", blockId: block.id });
}
const handleToggleLock = () => {
dispatch({ type: "toggle_lock", blockId: block.id });
// TODO
}
return (
@ -52,15 +39,15 @@ export default function Block({
type="text"
placeholder="enter tag..."
className="w-full focus:outline-none"
value={block.tag}
disabled={block.locked}
defaultValue={block.tag}
disabled={block.isLocked}
onChange={handleTagUpdate}
/>
{block.lines.map((line) => (
{block.lines.map((line, i) => (
<LineInput
key={line.id}
value={line.text}
disabled={block.locked}
key={i}
defaultValue={line}
disabled={block.isLocked}
onChange={(e) => handleLineUpdate(e, line)}
/>
))}
@ -74,8 +61,12 @@ export default function Block({
<IconOnlyButton onClick={handleBlockDown} icon={<ArrowDown size={18} />} />
</div>
<div className="flex gap-2 ml-auto">
<IconOnlyButton onClick={handleDeleteBlock} icon={<X size={18} />} />
<IconOnlyButton onClick={handleToggleLock} icon={block.locked ? <Lock size={18} /> : <LockOpen size={18} />} alwaysOn={block.locked} />
<IconOnlyButton onClick={() => deleteBlock(block)} icon={<X size={18} />} />
<IconOnlyButton
onClick={() => switchLock(block)}
icon={block.isLocked ? <Lock size={18} /> : <LockOpen size={18} />}
alwaysOn={block.isLocked}
/>
</div>
</div>
</div>

View File

@ -1,53 +1,69 @@
"use client";
import { ChangeEvent, useReducer } from "react";
import { ChangeEvent, useState } from "react";
import { Copy, Plus } from "lucide-react";
import { v4 as uuidv4 } from "uuid";
import { editorReducer, IBlock } from "@/lib/editorReducer";
import { createBlock } from "@/app/actions/blocks";
import { IBlock, INote } from "@/lib/db/schema";
import IconOnlyButton from "../ui/IconOnlyButton";
import Block from "./Block";
interface EditorProps {
defaultTitle?: string;
defaultBlocks?: IBlock[];
note? : INote;
blocks? : IBlock[];
}
const defaultBlocks = [
const defaultNoteId = uuidv4();
const defaultNote: INote = {
id: defaultNoteId,
title: "Untitled",
creationTime: new Date(),
lastEdited: new Date(),
authorId: uuidv4(),
};
const defaultBlocks: IBlock[] = [
{
id: uuidv4(),
tag: "",
locked: false,
lines: Array.from({ length: 4 }, () => ({
id: uuidv4(),
text: "",
})),
}
order: 1,
isLocked: false,
noteId: defaultNoteId,
lines: ["", "", "", ""],
},
];
export default function Editor(props: EditorProps) {
const [state, dispatch] = useReducer(editorReducer, {
title: props.defaultTitle || "Untitled",
blocks: props.defaultBlocks || defaultBlocks,
});
export default function Editor({ note = defaultNote, blocks = defaultBlocks }: EditorProps) {
const [title, setTitle] = useState(note.title);
const handleUpdateTitle = (e: ChangeEvent<HTMLInputElement>) => {
dispatch({ type: "update_title", title: e.target.value });
}
const handleAddBlock = () => {
dispatch({ type: "add_block" });
setTitle(e.target.value);
}
const handleCopy = () => {
dispatch({ type: "copy" });
let copyString = "";
blocks.forEach((block) => {
if (block.tag !== "") {
copyString += `[${block.tag}]`;
}
copyString += block.lines + "\n";
});
navigator.clipboard.writeText(copyString);
}
return (
<div className="flex flex-col items-center px-4 max-w-2xl w-full gap-4">
<input className="font-bold text-xl w-full text-center focus:outline-none" defaultValue={state.title} onChange={handleUpdateTitle} />
{state.blocks.map((block) => <Block key={block.id} block={block} dispatch={dispatch} /> )}
<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"
value={title}
onChange={handleUpdateTitle}
/>
{blocks.map((block) => <Block key={block.id} block={block} /> )}
<div className="flex gap-2">
<IconOnlyButton onClick={handleAddBlock} icon={<Plus size={24} />} />
<IconOnlyButton onClick={() => createBlock(note)} icon={<Plus size={24} />} />
<IconOnlyButton onClick={handleCopy} icon={<Copy size={24} />} title="Copy note to clipboard" />
</div>
</div>

View File

@ -1,8 +1,7 @@
import Link from "next/link";
import { revalidatePath } from "next/cache";
import { X } from "lucide-react";
import { notesTable } from "@/lib/db/schema";
import { deleteNote } from "@/lib/notes";
import { INote } from "@/lib/db/schema";
import { deleteNote } from "@/app/actions/notes";
import IconOnlyButton from "./IconOnlyButton";
function makeTimestamp(date: Date) {
@ -14,21 +13,20 @@ function makeTimestamp(date: Date) {
return `${day}.${month}.${year} ${hours}:${minutes}`;
}
export default function NoteCard({ note }: { note: typeof notesTable.$inferSelect }) {
const handleDeleteNote = async () => {
export default function NoteCard({ note }: { note: INote }) {
const handleDelete = async () => {
"use server";
await deleteNote(note.id);
revalidatePath("/notes");
}
return (
<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.name}</h1>
<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>
</Link>
<IconOnlyButton icon={<X size={24} />} onClick={handleDeleteNote} />
<IconOnlyButton icon={<X size={24} />} onClick={handleDelete} />
</div>
);
}

View File

@ -1,4 +1,4 @@
import { pgTable, json, varchar, timestamp, uuid } from "drizzle-orm/pg-core";
import { pgTable, varchar, timestamp, uuid, boolean, integer, json } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
export const usersTable = pgTable("users", {
@ -13,10 +13,10 @@ export const usersRelations = relations(usersTable, ({ many }) => ({
export const notesTable = pgTable("notes", {
id: uuid().primaryKey().defaultRandom(),
name: varchar({ length: 50 }).notNull().default("Untitled"),
title: varchar({ length: 50 }).notNull().default("Untitled"),
creationTime: timestamp().notNull().defaultNow(),
lastEdited: timestamp().notNull().defaultNow(),
authorId: uuid(),
authorId: uuid().notNull(),
});
export const notesRelations = relations(notesTable, ({ one }) => ({
@ -29,8 +29,10 @@ export const notesRelations = relations(notesTable, ({ one }) => ({
export const blocksTable = pgTable("blocks", {
id: uuid().primaryKey().defaultRandom(),
tag: varchar({ length: 100 }).notNull().default(""),
lines: json().notNull().default([]),
noteId: uuid(),
lines: json().notNull().default(["", "", "", ""]).$type<string[]>(),
isLocked: boolean().notNull().default(false),
order: integer().notNull().default(1),
noteId: uuid().notNull(),
});
export const blocksRelations = relations(blocksTable, ({ one }) => ({
@ -39,3 +41,7 @@ export const blocksRelations = relations(blocksTable, ({ one }) => ({
references: [notesTable.id],
}),
}));
export type IUser = typeof usersTable.$inferSelect;
export type INote = typeof notesTable.$inferSelect;
export type IBlock = typeof blocksTable.$inferSelect;

View File

@ -1,192 +0,0 @@
import { v4 as uuidv4 } from "uuid";
export type ILine = {
id: string;
text: string;
};
export type IBlock = {
id: string;
tag: string;
locked: boolean;
lines: ILine[];
};
type EditorState = {
title: string;
blocks: IBlock[];
};
export type Action =
| { type: "add_block" }
| { type: "copy" }
| { type: "delete_block", blockId: string }
| { type: "add_line", blockId: string }
| { type: "delete_line", blockId: string }
| { type: "update_title", title: string }
| { type: "update_line_text"; blockId: string; lineId: string; text: string }
| { type: "update_tag"; blockId: string; tag: string }
| { type: "move_block_up", blockId: string }
| { type: "move_block_down", blockId: string }
| { type: "toggle_lock", blockId: string };
export function editorReducer(state: EditorState, action: Action): EditorState {
switch (action.type) {
case "update_title":
return {
...state,
title: action.title,
};
case "add_block":
return {
...state,
blocks: [
...state.blocks,
{
id: uuidv4(),
tag: "",
locked: false,
lines: Array.from({ length: 4 }, () => ({
id: uuidv4(),
text: "",
})),
}
]
};
case "copy":
let copyText = "";
state.blocks.forEach((block) => {
if (block.tag !== "") {
copyText += `[${block.tag}]\n`;
}
block.lines.forEach((line) => {
if (line.text !== "") {
copyText += line.text + "\n";
}
})
copyText += "\n";
});
navigator.clipboard.writeText(copyText);
return state;
case "delete_block":
return {
...state,
blocks: state.blocks.filter((block) => block.id !== action.blockId),
};
case "add_line":
return {
...state,
blocks: state.blocks.map((block) => {
if (block.id === action.blockId) {
return {
...block,
lines: [...block.lines, { id: uuidv4(), text: "" }],
};
} else {
return block;
}
}),
};
case "delete_line":
return {
...state,
blocks: state.blocks.map((block) => {
if (block.id === action.blockId && block.lines.length > 0) {
return {
...block,
lines: block.lines.slice(0, -1),
};
} else {
return block;
}
}),
};
case "update_line_text":
return {
...state,
blocks: state.blocks.map((block) => {
if (block.id === action.blockId) {
return {
...block,
lines: block.lines.map((line) => {
if (line.id === action.lineId) {
return {
...line,
text: action.text,
};
} else {
return line;
}
}),
};
} else {
return block;
}
}),
};
case "update_tag":
return {
...state,
blocks: state.blocks.map((block) => {
if (block.id === action.blockId) {
return {
...block,
tag: action.tag,
};
} else {
return block;
}
}),
};
case "move_block_up": {
const index = state.blocks.findIndex((b) => b.id === action.blockId);
if (index <= 0) return state;
const newBlocks = [...state.blocks];
[newBlocks[index - 1], newBlocks[index]] = [newBlocks[index], newBlocks[index - 1]];
return {
...state,
blocks: newBlocks,
};
}
case "move_block_down": {
const index = state.blocks.findIndex((b) => b.id === action.blockId);
if (index === state.blocks.length - 1) return state;
const newBlocks = [...state.blocks];
[newBlocks[index], newBlocks[index + 1]] = [newBlocks[index + 1], newBlocks[index]];
return {
...state,
blocks: newBlocks,
};
}
case "toggle_lock":
return {
...state,
blocks: state.blocks.map((block) => {
if (block.id === action.blockId) {
return {
...block,
locked: !block.locked,
};
} else {
return block;
}
}),
};
default:
return state;
}
}

View File

@ -0,0 +1,9 @@
import { useEffect } from "react";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function useDebounce(callback: () => void, deps: any[]) {
useEffect(() => {
const timeout = setTimeout(callback, 1000);
return () => clearTimeout(timeout);
}, deps);
}