feat: add block functionality

This commit is contained in:
Kirill Siukhin 2025-07-15 03:58:33 +05:00
parent 646bbaaeb9
commit 32b33ae4c4
9 changed files with 326 additions and 20 deletions

View File

@ -0,0 +1,26 @@
CREATE TABLE "blocks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tag" varchar(100) DEFAULT '' NOT NULL,
"lines" json DEFAULT '["","","",""]'::json NOT NULL,
"isLocked" boolean DEFAULT false NOT NULL,
"order" integer DEFAULT 1 NOT NULL,
"noteId" uuid NOT NULL
);
--> statement-breakpoint
CREATE TABLE "notes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"title" varchar(50) DEFAULT 'Untitled' NOT NULL,
"creationTime" timestamp DEFAULT now() NOT NULL,
"lastEdited" timestamp DEFAULT now() NOT NULL,
"authorId" uuid NOT NULL
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"username" varchar(50) NOT NULL,
"password" varchar NOT NULL,
CONSTRAINT "users_username_unique" UNIQUE("username")
);
--> statement-breakpoint
ALTER TABLE "blocks" ADD CONSTRAINT "blocks_noteId_notes_id_fk" FOREIGN KEY ("noteId") REFERENCES "public"."notes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "notes" ADD CONSTRAINT "notes_authorId_users_id_fk" FOREIGN KEY ("authorId") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;

View File

@ -0,0 +1,188 @@
{
"id": "20af197f-8cc5-49a7-9066-ee214573f6ce",
"prevId": "00000000-0000-0000-0000-000000000000",
"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,
"default": 1
},
"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'"
},
"creationTime": {
"name": "creationTime",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"lastEdited": {
"name": "lastEdited",
"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

@ -1,5 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": []
"entries": [
{
"idx": 0,
"version": "7",
"when": 1752532763005,
"tag": "0000_cold_odin",
"breakpoints": true
}
]
}

39
src/app/actions/blocks.ts Normal file
View File

@ -0,0 +1,39 @@
"use server";
import { revalidatePath } from "next/cache";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { blocksTable, IBlock } from "@/lib/db/schema";
export async function createBlock(formData: FormData) {
const noteId = formData.get("noteId") as string;
await db
.insert(blocksTable)
.values({ noteId });
revalidatePath("/notes/[id]");
}
export async function getBlocks(noteId: string): Promise<IBlock[]> {
return db
.select()
.from(blocksTable)
.where(eq(blocksTable.noteId, noteId));
}
export async function deleteBlock(formData: FormData) {
const blockId = formData.get("blockId") as string;
await db
.delete(blocksTable)
.where(eq(blocksTable.id, blockId));
revalidatePath("/notes/[id]");
}
export async function changeLock(formData: FormData) {
const blockId = formData.get("blockId") as string;
const isLocked = formData.get("isLocked") === null ? false : true;
await db
.update(blocksTable)
.set({ isLocked })
.where(eq(blocksTable.id, blockId));
revalidatePath("/notes/[id]");
}

View File

@ -3,7 +3,7 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { desc, eq, and } from "drizzle-orm";
import { INote, notesTable, usersTable } from "@/lib/db/schema";
import { blocksTable, INote, notesTable, usersTable } from "@/lib/db/schema";
import { db } from "@/lib/db";
import { requireAuth } from "./auth";
@ -13,8 +13,19 @@ export async function createNote() {
.insert(notesTable)
.values({ authorId: user.id })
.returning({ id: usersTable.id });
const id = result[0].id;
redirect(`/notes/${id}`);
const noteId = result[0].id;
await db.insert(blocksTable).values({ noteId });
redirect(`/notes/${noteId}`);
}
export async function getNote(noteId: string): Promise<INote | null> {
const user = await requireAuth();
const note = await db
.select()
.from(notesTable)
.where(and(eq(notesTable.id, noteId), eq(notesTable.authorId, user.id)));
return note.length > 0 ? note[0] : null;
}
export async function getNotes(): Promise<INote[]> {

View File

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

View File

@ -1,4 +1,5 @@
import { ArrowDown, ArrowUp, LockOpen, Lock, Minus, Plus, X } from "lucide-react";
import { changeLock, deleteBlock } from "@/app/actions/blocks";
import { IBlock } from "@/lib/db/schema";
import IconOnlyButton from "../ui/IconOnlyButton";
import LineInput from "./LineInput";
@ -12,23 +13,33 @@ export default function Block({ block }: { block: IBlock }) {
placeholder="enter tag..."
className="w-full focus:outline-none"
defaultValue={block.tag}
disabled={block.isLocked}
/>
{block.lines.map((line, i) => <LineInput key={i} defaultValue={line} /> )}
{block.lines.map((line, i) => (
<LineInput key={i} defaultValue={line} disabled={block.isLocked} />
))}
<div className="flex items-center mx-2 mt-2">
<div className="flex gap-1 mr-4">
<IconOnlyButton icon={<Plus size={18} />} />
<IconOnlyButton icon={<Minus size={18} />} />
<IconOnlyButton icon={<Plus size={18} />} disabled={block.isLocked} />
<IconOnlyButton icon={<Minus size={18} />} disabled={block.isLocked} />
</div>
<div className="flex gap-1">
<IconOnlyButton icon={<ArrowUp size={18} />} />
<IconOnlyButton icon={<ArrowDown size={18} />} />
</div>
<div className="flex gap-2 ml-auto">
<IconOnlyButton icon={<X size={18} />} />
<IconOnlyButton
alwaysOn={block.isLocked}
icon={block.isLocked ? <Lock size={18} /> : <LockOpen size={18} />}
/>
<form action={deleteBlock}>
<input type="hidden" name="blockId" value={block.id} />
<IconOnlyButton icon={<X size={18} />} type="submit" />
</form>
<form action={changeLock}>
<input type="hidden" name="blockId" value={block.id} />
{!block.isLocked && <input type="hidden" name="isLocked" />}
<IconOnlyButton
alwaysOn={block.isLocked}
icon={block.isLocked ? <Lock size={18} /> : <LockOpen size={18} />}
/>
</form>
</div>
</div>
</div>

View File

@ -1,6 +1,7 @@
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 IconOnlyButton from "../ui/IconOnlyButton";
import Block from "./Block";
@ -34,10 +35,16 @@ export default function Editor({
}) {
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" defaultValue={note.title} />
<input
className="font-bold text-xl w-full text-center focus:outline-none"
defaultValue={note.title}
/>
{blocks.map((block) => <Block key={block.id} block={block} /> )}
<div className="flex gap-2">
<IconOnlyButton icon={<Plus size={24} />} />
<form action={createBlock}>
<input type="hidden" name="noteId" value={note.id} />
<IconOnlyButton icon={<Plus size={24} />} type="submit" />
</form>
<IconOnlyButton icon={<Copy size={24} />} title="Copy note to clipboard" />
</div>
</div>

View File

@ -16,14 +16,15 @@ export const notesTable = pgTable("notes", {
title: varchar({ length: 50 }).notNull().default("Untitled"),
creationTime: timestamp().notNull().defaultNow(),
lastEdited: timestamp().notNull().defaultNow(),
authorId: uuid().notNull(),
authorId: uuid().notNull().references(() => usersTable.id, { onDelete: "cascade" }),
});
export const notesRelations = relations(notesTable, ({ one }) => ({
export const notesRelations = relations(notesTable, ({ one, many }) => ({
author: one(usersTable, {
fields: [notesTable.authorId],
references: [usersTable.id],
}),
block: many(blocksTable),
}));
export const blocksTable = pgTable("blocks", {
@ -32,7 +33,7 @@ export const blocksTable = pgTable("blocks", {
lines: json().notNull().default(["", "", "", ""]).$type<string[]>(),
isLocked: boolean().notNull().default(false),
order: integer().notNull().default(1),
noteId: uuid().notNull(),
noteId: uuid().notNull().references(() => notesTable.id, { onDelete: "cascade" }),
});
export const blocksRelations = relations(blocksTable, ({ one }) => ({
@ -40,6 +41,10 @@ export const blocksRelations = relations(blocksTable, ({ one }) => ({
fields: [blocksTable.noteId],
references: [notesTable.id],
}),
block: one(blocksTable, {
fields: [blocksTable.id],
references: [blocksTable.id],
}),
}));
export type IUser = typeof usersTable.$inferSelect;