├── .eslintrc.json
├── app
├── favicon.ico
├── actions.ts
├── new
│ ├── page.tsx
│ └── TextEditor.tsx
├── page.tsx
├── layout.tsx
├── globals.css
└── api
│ └── generate
│ └── route.ts
├── next.config.js
├── postcss.config.js
├── lib
└── utils.ts
├── prisma
├── schema.prisma
└── client.tsx
├── components.json
├── .gitignore
├── tailwind.config.ts
├── public
├── vercel.svg
└── next.svg
├── README.md
├── tsconfig.json
├── package.json
├── components
└── ui
│ └── button.tsx
└── tailwind.config.js
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Gyurmatag/note-blocks/HEAD/app/favicon.ico
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {}
3 |
4 | module.exports = nextConfig
5 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/app/actions.ts:
--------------------------------------------------------------------------------
1 | 'use server'
2 |
3 | import prisma from "@/prisma/client";
4 |
5 | type Note = {
6 | document: object
7 | }
8 |
9 | export async function createNote(note: Note) {
10 | return prisma.note.create({
11 | data: note,
12 | });
13 | }
14 |
--------------------------------------------------------------------------------
/app/new/page.tsx:
--------------------------------------------------------------------------------
1 | import dynamic from "next/dynamic";
2 |
3 | const TextEditor = dynamic(() => import("./TextEditor"), { ssr: false });
4 |
5 | export default function Page() {
6 | return (
7 |
8 |
9 |
10 | )
11 | }
12 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | datasource db {
2 | provider = "postgresql"
3 | url = env("DATABASE_URL")
4 | relationMode = "prisma"
5 | }
6 |
7 | generator client {
8 | provider = "prisma-client-js"
9 | }
10 |
11 | model Note {
12 | id String @id @default(cuid())
13 | createdAt DateTime @default(now())
14 | updatedAt DateTime @updatedAt
15 | document Json? @db.Json
16 | }
17 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "app/globals.css",
9 | "baseColor": "gray",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
--------------------------------------------------------------------------------
/prisma/client.tsx:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from '@prisma/client';
2 | declare global {
3 | namespace NodeJS {
4 | interface Global {}
5 | }
6 | }
7 | interface CustomNodeJsGlobal extends NodeJS.Global {
8 | prisma: PrismaClient;
9 | }
10 | declare const global: CustomNodeJsGlobal;
11 |
12 | const prisma = global.prisma || new PrismaClient();
13 |
14 | if (process.env.NODE_ENV !== 'production') global.prisma = prisma;
15 |
16 | export default prisma;
17 |
--------------------------------------------------------------------------------
/app/page.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import { Button } from "@/components/ui/button"
3 |
4 | export default function Home() {
5 | return (
6 |
7 |
8 |
13 |
14 |
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next'
2 | import { Inter } from 'next/font/google'
3 | import './globals.css'
4 |
5 | const inter = Inter({ subsets: ['latin'] })
6 |
7 | export const metadata: Metadata = {
8 | title: 'Create Next App',
9 | description: 'Generated by create next app',
10 | }
11 |
12 | export default function RootLayout({
13 | children,
14 | }: {
15 | children: React.ReactNode
16 | }) {
17 | return (
18 |
19 | {children}
20 |
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'tailwindcss'
2 |
3 | const config: Config = {
4 | content: [
5 | './pages/**/*.{js,ts,jsx,tsx,mdx}',
6 | './components/**/*.{js,ts,jsx,tsx,mdx}',
7 | './app/**/*.{js,ts,jsx,tsx,mdx}',
8 | ],
9 | theme: {
10 | extend: {
11 | backgroundImage: {
12 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
13 | 'gradient-conic':
14 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
15 | },
16 | },
17 | },
18 | plugins: [],
19 | }
20 | export default config
21 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## Note Blocks
2 |
3 | If you clone this repo you should run a:
4 |
5 | ```bash
6 | npm install
7 | ```
8 |
9 | After that you must create a `.env` file in the root of the project and add the following:
10 |
11 | ```bash
12 | OPENAI_API_KEY=
13 | ```
14 |
15 | Than you can run the project with:
16 |
17 | ```bash
18 | npm run dev
19 | ```
20 |
21 | For more guidance (like configuring database, etc) you can check [this article](https://dev.to/shiwaforce/notion-like-text-editor-with-ai-autocomplete-and-planetscale-database-in-nextjs-using-shadcnui-4236) for the repo.
22 | In there I will guide you through the whole project creation process step by step.
23 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "note-blocks",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@blocknote/core": "^0.12.4",
13 | "@blocknote/react": "^0.12.4",
14 | "@prisma/client": "^5.7.1",
15 | "@radix-ui/react-slot": "^1.0.2",
16 | "@upstash/ratelimit": "^1.0.0",
17 | "@vercel/kv": "^1.0.1",
18 | "ai": "^2.2.31",
19 | "class-variance-authority": "^0.7.0",
20 | "clsx": "^2.1.0",
21 | "lucide-react": "^0.307.0",
22 | "next": "14.0.4",
23 | "openai": "^4.24.1",
24 | "react": "^18",
25 | "react-dom": "^18",
26 | "tailwind-merge": "^2.2.0",
27 | "tailwindcss-animate": "^1.0.7"
28 | },
29 | "devDependencies": {
30 | "@types/node": "^20",
31 | "@types/react": "^18",
32 | "@types/react-dom": "^18",
33 | "autoprefixer": "^10.0.1",
34 | "eslint": "^8",
35 | "eslint-config-next": "14.0.4",
36 | "postcss": "^8",
37 | "prisma": "^5.7.1",
38 | "tailwindcss": "^3.3.0",
39 | "typescript": "^5"
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 224 71.4% 4.1%;
9 |
10 | --card: 0 0% 100%;
11 | --card-foreground: 224 71.4% 4.1%;
12 |
13 | --popover: 0 0% 100%;
14 | --popover-foreground: 224 71.4% 4.1%;
15 |
16 | --primary: 220.9 39.3% 11%;
17 | --primary-foreground: 210 20% 98%;
18 |
19 | --secondary: 220 14.3% 95.9%;
20 | --secondary-foreground: 220.9 39.3% 11%;
21 |
22 | --muted: 220 14.3% 95.9%;
23 | --muted-foreground: 220 8.9% 46.1%;
24 |
25 | --accent: 220 14.3% 95.9%;
26 | --accent-foreground: 220.9 39.3% 11%;
27 |
28 | --destructive: 0 84.2% 60.2%;
29 | --destructive-foreground: 210 20% 98%;
30 |
31 | --border: 220 13% 91%;
32 | --input: 220 13% 91%;
33 | --ring: 224 71.4% 4.1%;
34 |
35 | --radius: 0.5rem;
36 | }
37 |
38 | .dark {
39 | --background: 224 71.4% 4.1%;
40 | --foreground: 210 20% 98%;
41 |
42 | --card: 224 71.4% 4.1%;
43 | --card-foreground: 210 20% 98%;
44 |
45 | --popover: 224 71.4% 4.1%;
46 | --popover-foreground: 210 20% 98%;
47 |
48 | --primary: 210 20% 98%;
49 | --primary-foreground: 220.9 39.3% 11%;
50 |
51 | --secondary: 215 27.9% 16.9%;
52 | --secondary-foreground: 210 20% 98%;
53 |
54 | --muted: 215 27.9% 16.9%;
55 | --muted-foreground: 217.9 10.6% 64.9%;
56 |
57 | --accent: 215 27.9% 16.9%;
58 | --accent-foreground: 210 20% 98%;
59 |
60 | --destructive: 0 62.8% 30.6%;
61 | --destructive-foreground: 210 20% 98%;
62 |
63 | --border: 215 27.9% 16.9%;
64 | --input: 215 27.9% 16.9%;
65 | --ring: 216 12.2% 83.9%;
66 | }
67 | }
68 |
69 | @layer base {
70 | * {
71 | @apply border-border;
72 | }
73 | body {
74 | @apply bg-background text-foreground;
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
13 | destructive:
14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90",
15 | outline:
16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
17 | secondary:
18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19 | ghost: "hover:bg-accent hover:text-accent-foreground",
20 | link: "text-primary underline-offset-4 hover:underline",
21 | },
22 | size: {
23 | default: "h-10 px-4 py-2",
24 | sm: "h-9 rounded-md px-3",
25 | lg: "h-11 rounded-md px-8",
26 | icon: "h-10 w-10",
27 | },
28 | },
29 | defaultVariants: {
30 | variant: "default",
31 | size: "default",
32 | },
33 | }
34 | )
35 |
36 | export interface ButtonProps
37 | extends React.ButtonHTMLAttributes,
38 | VariantProps {
39 | asChild?: boolean
40 | }
41 |
42 | const Button = React.forwardRef(
43 | ({ className, variant, size, asChild = false, ...props }, ref) => {
44 | const Comp = asChild ? Slot : "button"
45 | return (
46 |
51 | )
52 | }
53 | )
54 | Button.displayName = "Button"
55 |
56 | export { Button, buttonVariants }
57 |
--------------------------------------------------------------------------------
/app/api/generate/route.ts:
--------------------------------------------------------------------------------
1 | import OpenAI from 'openai';
2 | import { OpenAIStream, StreamingTextResponse } from 'ai';
3 | import { kv } from '@vercel/kv';
4 | import { Ratelimit } from '@upstash/ratelimit';
5 |
6 | const openai = new OpenAI({
7 | apiKey: process.env.OPENAI_API_KEY || '',
8 | });
9 |
10 | export const runtime = 'edge';
11 |
12 | export async function POST(req: Request): Promise {
13 | if (!process.env.OPENAI_API_KEY || process.env.OPENAI_API_KEY === '') {
14 | return new Response(
15 | 'Missing OPENAI_API_KEY – make sure to add it to your .env file.',
16 | {
17 | status: 400,
18 | }
19 | );
20 | }
21 | if (
22 | process.env.NODE_ENV != 'development' &&
23 | process.env.KV_REST_API_URL &&
24 | process.env.KV_REST_API_TOKEN
25 | ) {
26 | const ip = req.headers.get('x-forwarded-for');
27 | const ratelimit = new Ratelimit({
28 | redis: kv,
29 | limiter: Ratelimit.slidingWindow(50, '1 d'),
30 | });
31 |
32 | const { success, limit, reset, remaining } = await ratelimit.limit(
33 | `noteblock_ratelimit_${ip}`
34 | );
35 |
36 | if (!success) {
37 | return new Response('You have reached your request limit for the day.', {
38 | status: 429,
39 | headers: {
40 | 'X-RateLimit-Limit': limit.toString(),
41 | 'X-RateLimit-Remaining': remaining.toString(),
42 | 'X-RateLimit-Reset': reset.toString(),
43 | },
44 | });
45 | }
46 | }
47 |
48 | let { prompt } = await req.json();
49 |
50 | const response = await openai.chat.completions.create({
51 | model: 'gpt-3.5-turbo',
52 | messages: [
53 | {
54 | role: 'system',
55 | content:
56 | 'You are an AI writing assistant that continues existing text based on context from prior text. ' +
57 | 'Give more weight/priority to the later characters than the beginning ones. ' +
58 | 'Limit your response to no more than 200 characters, but make sure to construct complete sentences.',
59 | },
60 | {
61 | role: 'user',
62 | content: prompt,
63 | },
64 | ],
65 | temperature: 0.7,
66 | top_p: 1,
67 | frequency_penalty: 0,
68 | presence_penalty: 0,
69 | stream: true,
70 | n: 1,
71 | });
72 |
73 | const stream = OpenAIStream(response);
74 |
75 | return new StreamingTextResponse(stream);
76 | }
77 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ["class"],
4 | content: [
5 | './pages/**/*.{ts,tsx}',
6 | './components/**/*.{ts,tsx}',
7 | './app/**/*.{ts,tsx}',
8 | './src/**/*.{ts,tsx}',
9 | ],
10 | prefix: "",
11 | theme: {
12 | container: {
13 | center: true,
14 | padding: "2rem",
15 | screens: {
16 | "2xl": "1400px",
17 | },
18 | },
19 | extend: {
20 | colors: {
21 | border: "hsl(var(--border))",
22 | input: "hsl(var(--input))",
23 | ring: "hsl(var(--ring))",
24 | background: "hsl(var(--background))",
25 | foreground: "hsl(var(--foreground))",
26 | primary: {
27 | DEFAULT: "hsl(var(--primary))",
28 | foreground: "hsl(var(--primary-foreground))",
29 | },
30 | secondary: {
31 | DEFAULT: "hsl(var(--secondary))",
32 | foreground: "hsl(var(--secondary-foreground))",
33 | },
34 | destructive: {
35 | DEFAULT: "hsl(var(--destructive))",
36 | foreground: "hsl(var(--destructive-foreground))",
37 | },
38 | muted: {
39 | DEFAULT: "hsl(var(--muted))",
40 | foreground: "hsl(var(--muted-foreground))",
41 | },
42 | accent: {
43 | DEFAULT: "hsl(var(--accent))",
44 | foreground: "hsl(var(--accent-foreground))",
45 | },
46 | popover: {
47 | DEFAULT: "hsl(var(--popover))",
48 | foreground: "hsl(var(--popover-foreground))",
49 | },
50 | card: {
51 | DEFAULT: "hsl(var(--card))",
52 | foreground: "hsl(var(--card-foreground))",
53 | },
54 | },
55 | borderRadius: {
56 | lg: "var(--radius)",
57 | md: "calc(var(--radius) - 2px)",
58 | sm: "calc(var(--radius) - 4px)",
59 | },
60 | keyframes: {
61 | "accordion-down": {
62 | from: { height: "0" },
63 | to: { height: "var(--radix-accordion-content-height)" },
64 | },
65 | "accordion-up": {
66 | from: { height: "var(--radix-accordion-content-height)" },
67 | to: { height: "0" },
68 | },
69 | },
70 | animation: {
71 | "accordion-down": "accordion-down 0.2s ease-out",
72 | "accordion-up": "accordion-up 0.2s ease-out",
73 | },
74 | },
75 | },
76 | plugins: [require("tailwindcss-animate")],
77 | }
--------------------------------------------------------------------------------
/app/new/TextEditor.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { BlockNoteEditor, filterSuggestionItems } from "@blocknote/core";
4 | import {
5 | BlockNoteView,
6 | DefaultReactSuggestionItem,
7 | getDefaultReactSlashMenuItems, SuggestionMenuController,
8 | useCreateBlockNote
9 | } from "@blocknote/react";
10 | import "@blocknote/core/style.css";
11 | import { ImMagicWand } from "react-icons/im";
12 | import { useCompletion } from "ai/react";
13 | import { createNote } from "@/app/actions";
14 | import { Button } from "@/components/ui/button";
15 | import '@blocknote/core/fonts/inter.css';
16 | import '@blocknote/react/style.css';
17 |
18 | export default function TextEditor() {
19 | const { complete } = useCompletion({
20 | id: 'note_blocks',
21 | api: '/api/generate',
22 | onResponse: (response) => {
23 | if (response.status === 429) {
24 | return;
25 | }
26 | if (response.body) {
27 | const reader = response.body.getReader();
28 | let decoder = new TextDecoder();
29 |
30 | reader.read().then(function processText({ done, value }) {
31 | if (done) {
32 | return;
33 | }
34 |
35 | let chunk = decoder.decode(value, { stream: true });
36 |
37 | editor?._tiptapEditor.commands.insertContent(chunk);
38 |
39 | reader.read().then(processText);
40 | });
41 | } else {
42 | console.error('Response body is null');
43 | }
44 | },
45 | onError: (e) => {
46 | console.error(e.message);
47 | },
48 | });
49 |
50 | const insertMagicAi = (editor: BlockNoteEditor) => {
51 | const prevText = editor._tiptapEditor.state.doc.textBetween(
52 | Math.max(0, editor._tiptapEditor.state.selection.from - 5000),
53 | editor._tiptapEditor.state.selection.from - 1,
54 | '\n'
55 | );
56 | complete(prevText);
57 | };
58 |
59 | const insertMagicItem = (editor: BlockNoteEditor) => ({
60 | title: 'Insert Magic Text',
61 | onItemClick: async () => {
62 | const prevText = editor._tiptapEditor.state.doc.textBetween(
63 | Math.max(0, editor._tiptapEditor.state.selection.from - 5000),
64 | editor._tiptapEditor.state.selection.from - 1,
65 | '\n'
66 | );
67 | insertMagicAi(editor);
68 | },
69 | aliases: ['autocomplete', 'ai'],
70 | group: 'AI',
71 | icon: ,
72 | subtext: 'Continue your note with AI-generated text',
73 | });
74 |
75 | const getCustomSlashMenuItems = (
76 | editor: BlockNoteEditor
77 | ): DefaultReactSuggestionItem[] => [
78 | ...getDefaultReactSlashMenuItems(editor),
79 | insertMagicItem(editor),
80 | ];
81 |
82 | const editor = useCreateBlockNote({
83 | });
84 |
85 | const handleSubmitNote = async () => {
86 | const note = {
87 | document: editor.document
88 | }
89 | await createNote(note)
90 | }
91 |
92 | return (
93 |
94 |
95 |
99 |
102 | filterSuggestionItems(getCustomSlashMenuItems(editor), query)
103 | }
104 | />
105 |
106 |
107 |
113 |
114 |
115 |
116 | );
117 | }
118 |
--------------------------------------------------------------------------------