├── .eslintrc.json ├── .gitignore ├── README.md ├── app ├── actions.ts ├── api │ └── generate │ │ └── route.ts ├── favicon.ico ├── globals.css ├── layout.tsx ├── new │ ├── TextEditor.tsx │ └── page.tsx └── page.tsx ├── components.json ├── components └── ui │ └── button.tsx ├── lib └── utils.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── prisma ├── client.tsx └── schema.prisma ├── public ├── next.svg └── vercel.svg ├── tailwind.config.js ├── tailwind.config.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gyurmatag/note-blocks/61295c39be4190639d389ff7d329df13c8f9ae29/app/favicon.ico -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {} 3 | 4 | module.exports = nextConfig 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------