├── .nvmrc
├── embedchain-backend
├── .python-version
├── api
│ └── routes
│ │ ├── __init__.py
│ │ ├── admin.py
│ │ └── api.py
├── example.env
├── requirements.txt
└── main.py
├── bun.lockb
├── public
├── logo.png
├── ogimage.png
├── icon512_rounded.png
├── icon512_maskable.png
├── google.svg
├── manifest.json
└── umami.js
├── src
├── app
│ ├── api
│ │ ├── auth
│ │ │ └── [...nextauth]
│ │ │ │ └── route.ts
│ │ ├── upload
│ │ │ └── route.ts
│ │ ├── fetchPosts
│ │ │ └── route.ts
│ │ ├── search
│ │ │ └── route.ts
│ │ ├── generate
│ │ │ └── route.ts
│ │ └── note
│ │ │ └── route.ts
│ ├── note
│ │ ├── [id]
│ │ │ ├── page.tsx
│ │ │ ├── novelEditor.tsx
│ │ │ └── defaultData.ts
│ │ ├── new
│ │ │ └── page.tsx
│ │ └── page.tsx
│ ├── providers.tsx
│ ├── ui
│ │ ├── primitives
│ │ │ └── popover.tsx
│ │ └── menu.tsx
│ ├── layout.tsx
│ ├── page.tsx
│ └── drawer.tsx
├── types
│ ├── note.ts
│ └── aiResponse.ts
├── lib
│ ├── utils.ts
│ ├── auth.ts
│ ├── note.ts
│ └── context
│ │ └── NotesContext.tsx
├── styles
│ └── globals.css
├── server
│ └── db
│ │ ├── index.ts
│ │ └── schema.ts
├── components
│ ├── ui
│ │ ├── label.tsx
│ │ ├── input.tsx
│ │ ├── card.tsx
│ │ └── button.tsx
│ ├── skeletonLoader.tsx
│ ├── search-results.tsx
│ ├── warning.tsx
│ ├── NewNoteButton.tsx
│ └── DarkModeSwitch.tsx
└── env.js
├── nottykv-cloudflare-worker
├── bun.lockb
├── .prettierrc
├── tsconfig.json
├── .editorconfig
├── wrangler.toml
├── package.json
├── src
│ └── worker.ts
└── .gitignore
├── .vercelignore
├── postcss.config.cjs
├── prettier.config.js
├── next.config.mjs
├── drizzle.config.ts
├── components.json
├── .env.example
├── tsconfig.json
├── .gitignore
├── LICENSE
├── .eslintrc.cjs
├── tailwind.config.ts
├── package.json
└── README.md
/.nvmrc:
--------------------------------------------------------------------------------
1 | v20
--------------------------------------------------------------------------------
/embedchain-backend/.python-version:
--------------------------------------------------------------------------------
1 | 3.10
--------------------------------------------------------------------------------
/embedchain-backend/api/routes/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/code/app-notty/main/bun.lockb
--------------------------------------------------------------------------------
/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/code/app-notty/main/public/logo.png
--------------------------------------------------------------------------------
/embedchain-backend/example.env:
--------------------------------------------------------------------------------
1 | OPENAI_API_KEY=sk-**************
2 | AUTH_TOKEN=AUTH_TOKEN
--------------------------------------------------------------------------------
/public/ogimage.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/code/app-notty/main/public/ogimage.png
--------------------------------------------------------------------------------
/public/icon512_rounded.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/code/app-notty/main/public/icon512_rounded.png
--------------------------------------------------------------------------------
/public/icon512_maskable.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/code/app-notty/main/public/icon512_maskable.png
--------------------------------------------------------------------------------
/src/app/api/auth/[...nextauth]/route.ts:
--------------------------------------------------------------------------------
1 | export { GET, POST } from "@/lib/auth";
2 | export const runtime = "edge";
3 |
--------------------------------------------------------------------------------
/nottykv-cloudflare-worker/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/code/app-notty/main/nottykv-cloudflare-worker/bun.lockb
--------------------------------------------------------------------------------
/.vercelignore:
--------------------------------------------------------------------------------
1 | nottykv-cloudflare-worker/
2 | sw.js
3 | sw.js.map
4 | workbox-*.js
5 | workbox-*.js.map
6 | embedchain-backend/
--------------------------------------------------------------------------------
/nottykv-cloudflare-worker/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 140,
3 | "singleQuote": true,
4 | "semi": true,
5 | "useTabs": true
6 | }
7 |
--------------------------------------------------------------------------------
/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | const config = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
8 | module.exports = config;
9 |
--------------------------------------------------------------------------------
/src/types/note.ts:
--------------------------------------------------------------------------------
1 | export type Value = {
2 | type: string;
3 | content: [{ type: string; content: [{ text: string; type: string }] }];
4 | };
5 |
--------------------------------------------------------------------------------
/embedchain-backend/requirements.txt:
--------------------------------------------------------------------------------
1 | python-dotenv
2 | embedchain
3 | fastapi==0.108.0
4 | uvicorn==0.25.0
5 | embedchain
6 | beautifulsoup4
7 | sentence-transformers
8 |
--------------------------------------------------------------------------------
/src/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 |
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
2 | const config = {
3 | plugins: ["prettier-plugin-tailwindcss"],
4 | };
5 |
6 | export default config;
7 |
--------------------------------------------------------------------------------
/nottykv-cloudflare-worker/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "module": "CommonJS",
5 | "lib": ["ES2020"],
6 | "types": ["@cloudflare/workers-types"],
7 | "strictNullChecks": true,
8 | },
9 | }
10 |
--------------------------------------------------------------------------------
/src/app/note/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { redirect } from "next/navigation";
4 |
5 | function Page({ params }: { params: { id: string } }) {
6 | return redirect(`/note?id=${params.id}`)
7 | }
8 |
9 | export default Page;
10 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | import withPWAInit from "@ducanh2912/next-pwa";
2 |
3 | const withPWA = withPWAInit({
4 | dest: "public",
5 | register: true,
6 | reloadOnOnline: true,
7 | });
8 |
9 | export default withPWA({
10 | // Your Next.js config
11 | });
--------------------------------------------------------------------------------
/nottykv-cloudflare-worker/.editorconfig:
--------------------------------------------------------------------------------
1 | # http://editorconfig.org
2 | root = true
3 |
4 | [*]
5 | indent_style = tab
6 | tab_width = 2
7 | end_of_line = lf
8 | charset = utf-8
9 | trim_trailing_whitespace = true
10 | insert_final_newline = true
11 |
12 | [*.yml]
13 | indent_style = space
14 |
--------------------------------------------------------------------------------
/src/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @keyframes gradient {
6 | 0% {
7 | background-position: 0% 50%;
8 | }
9 | 50% {
10 | background-position: 100% 50%;
11 | }
12 | 100% {
13 | background-position: 0% 50%;
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/nottykv-cloudflare-worker/wrangler.toml:
--------------------------------------------------------------------------------
1 | name = "nottykv"
2 | main = "src/worker.ts"
3 | compatibility_date = "2024-01-31"
4 | workers_dev = true
5 |
6 | kv_namespaces = [
7 | { binding = "nottykv", id = "84b903d3a6b94c82a0aed0d0a30d43e7" }
8 | ]
9 |
10 | [vars]
11 | SECURITY_KEY = "mIjoGX3Fbl+Mc6ibQccpXw=="
12 |
--------------------------------------------------------------------------------
/drizzle.config.ts:
--------------------------------------------------------------------------------
1 | import { type Config } from "drizzle-kit";
2 |
3 | import { env } from "@/env.js";
4 |
5 | export default {
6 | schema: "./src/server/db/schema.ts",
7 | driver: "better-sqlite",
8 | dbCredentials: {
9 | url: env.DATABASE_URL,
10 | },
11 | tablesFilter: ["notes_*"],
12 | } satisfies Config;
13 |
--------------------------------------------------------------------------------
/src/app/note/new/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { redirect } from 'next/navigation'
4 |
5 | function Route() {
6 | // Generate a 10 digit number
7 | const nextNoteId = Math.floor(Math.random() * 9000000000) + 1000000000;
8 |
9 | return redirect(`/note?id=${nextNoteId}`);
10 | }
11 |
12 | export default Route
--------------------------------------------------------------------------------
/src/server/db/index.ts:
--------------------------------------------------------------------------------
1 | import Database from "better-sqlite3";
2 | import { drizzle } from "drizzle-orm/better-sqlite3";
3 |
4 | import { env } from "@/env.js";
5 | import * as schema from "./schema";
6 |
7 | export const db = drizzle(
8 | new Database(env.DATABASE_URL, {
9 | fileMustExist: false,
10 | }),
11 | { schema },
12 | );
13 |
--------------------------------------------------------------------------------
/nottykv-cloudflare-worker/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nottykv",
3 | "version": "0.0.0",
4 | "private": true,
5 | "scripts": {
6 | "deploy": "wrangler deploy",
7 | "dev": "wrangler dev",
8 | "start": "wrangler dev"
9 | },
10 | "devDependencies": {
11 | "wrangler": "^3.0.0"
12 | },
13 | "dependencies": {
14 | "@cloudflare/workers-types": "^4.20240129.0",
15 | "next-pwa": "^5.6.0"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "src/styles/globals.css",
9 | "baseColor": "stone",
10 | "cssVariables": false,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/app/note/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { notFound } from "next/navigation";
4 | import NovelEditor from "./[id]/novelEditor";
5 |
6 | function Page({ searchParams }: { searchParams: { id: string } }) {
7 |
8 | if (!searchParams.id){
9 | notFound()
10 | }
11 |
12 | return (
13 |
14 |
15 |
16 | );
17 | }
18 |
19 | export default Page;
20 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | DATABASE_URL="db.sqlite"
2 |
3 | # For AI generated content
4 | OPENROUTER_API_TOKEN=
5 |
6 | # VercelKV For ratelimiting
7 | KV_REST_API_URL=
8 | KV_REST_API_TOKEN=
9 |
10 |
11 |
12 | # For authentication
13 | GOOGLE_CLIENT_ID=
14 | GOOGLE_CLIENT_SECRET=
15 |
16 | # For Frontend <> Worker communication
17 | AUTH_SECRET=
18 | # For image storage,being used for communication token for api auth as well
19 | CLOUDFLARE_R2_TOKEN=
20 | # cloudflare worker base url
21 | WORKER_BASE_URL=
22 | # embedchain worked base url
23 | BACKEND_BASE_URL=
--------------------------------------------------------------------------------
/src/lib/auth.ts:
--------------------------------------------------------------------------------
1 | import { env } from "@/env";
2 | import NextAuth from "next-auth";
3 | import Google from "next-auth/providers/google";
4 |
5 | export const {
6 | handlers: { GET, POST },
7 | auth,
8 | } = NextAuth({
9 | providers: [
10 | Google({
11 | clientId: env.GOOGLE_CLIENT_ID,
12 | clientSecret: env.GOOGLE_CLIENT_SECRET,
13 | authorization: {
14 | params: {
15 | prompt: "consent",
16 | access_type: "offline",
17 | response_type: "code",
18 | },
19 | },
20 | }),
21 | ],
22 | });
23 |
--------------------------------------------------------------------------------
/src/types/aiResponse.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod"
2 |
3 | const searchResult = z.array(
4 | z.tuple([
5 | z.string(),
6 | z.object({
7 | app_id: z.string(),
8 | data_type: z.string(),
9 | doc_id: z.string(),
10 | hash: z.string(),
11 | note_id: z.string(),
12 | url: z.string(),
13 | user: z.string(),
14 | score: z.number()
15 | })
16 | ]
17 | )
18 | )
19 |
20 | export const aiResponse = z.tuple([
21 | z.string(),
22 | searchResult
23 | ])
24 |
25 | export type AiResponse = z.infer
26 |
--------------------------------------------------------------------------------
/src/app/providers.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { type ReactNode } from "react";
4 | // import { ThemeProvider } from 'next-themes';
5 | import { SessionProvider } from "next-auth/react";
6 | import { NotesProvider } from "@/lib/context/NotesContext";
7 |
8 | export default function Providers({ children }: { children: ReactNode }) {
9 | return (
10 | //
17 |
18 |
19 | {children}
20 |
21 |
22 | //
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/public/google.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 | "@/*": ["./src/*"],
23 | },
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules", "nottykv-cloudflare-worker"],
27 | }
28 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export type InputProps = React.InputHTMLAttributes
6 |
7 | const Input = React.forwardRef(
8 | ({ className, type, ...props }, ref) => {
9 | return (
10 |
19 | )
20 | }
21 | )
22 | Input.displayName = "Input"
23 |
24 | export { Input }
25 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 | wrangler.toml
3 | sw.js
4 | sw.js.map
5 | workbox-*.js
6 | workbox-*.js.map
7 |
8 | embedchain-backend/db/
9 | embedchain-backend/env/
10 |
11 | # dependencies
12 | /node_modules
13 | /.pnp
14 | .pnp.js
15 |
16 | # testing
17 | /coverage
18 |
19 | # database
20 | /prisma/db.sqlite
21 | /prisma/db.sqlite-journal
22 |
23 | # next.js
24 | /.next/
25 | /out/
26 | next-env.d.ts
27 |
28 | # production
29 | /build
30 |
31 | # misc
32 | .DS_Store
33 | *.pem
34 |
35 | # debug
36 | npm-debug.log*
37 | yarn-debug.log*
38 | yarn-error.log*
39 | .pnpm-debug.log*
40 |
41 | # local env files
42 | # do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
43 | .env
44 | .env*.local
45 |
46 | # vercel
47 | .vercel
48 |
49 | # typescript
50 | *.tsbuildinfo
51 |
52 | certificates
53 | embedchain-backend/venv
54 | **/__pycache__/*
--------------------------------------------------------------------------------
/embedchain-backend/main.py:
--------------------------------------------------------------------------------
1 | import uvicorn
2 | from dotenv import load_dotenv
3 | from fastapi import FastAPI, HTTPException, Request
4 | from os import environ as env
5 |
6 | from api.routes import admin, api
7 |
8 | load_dotenv()
9 |
10 | app = FastAPI(title="Embedchain API")
11 |
12 | app.include_router(api.router)
13 | app.include_router(admin.router)
14 |
15 |
16 | @app.middleware("http")
17 | async def token_check_middleware(request: Request, call_next):
18 | token = request.headers.get("Authorization")
19 |
20 | if request.url.path.startswith("/api/v1"):
21 | if token != env.get("AUTH_TOKEN"):
22 | raise HTTPException(status_code=401, detail="Unauthorized")
23 | response = await call_next(request)
24 | return response
25 |
26 |
27 | if __name__ == "__main__":
28 | uvicorn.run(
29 | "main:app",
30 | host="0.0.0.0",
31 | port=8000,
32 | log_level="info",
33 | reload=True,
34 | timeout_keep_alive=600,
35 | )
36 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "theme_color": "#8936FF",
3 | "background_color": "#64cfdf",
4 | "icons": [
5 | {
6 | "purpose": "maskable",
7 | "sizes": "512x512",
8 | "src": "icon512_maskable.png",
9 | "type": "image/png"
10 | },
11 | {
12 | "purpose": "any",
13 | "sizes": "512x512",
14 | "src": "icon512_rounded.png",
15 | "type": "image/png"
16 | }
17 | ],
18 | "orientation": "portrait",
19 | "display": "standalone",
20 | "dir": "auto",
21 | "lang": "en-US",
22 | "name": "Notty - notes, simplified",
23 | "short_name": "Notty",
24 | "description": "Notty is a simple, minimal AI powered note taking app and markdown editor - Built local-first, with cloud sync. It uses AI to help you write and stay productive.",
25 | "id": "notty",
26 | "start_url": "/",
27 | "shortcuts": [
28 | {
29 | "name": "New note",
30 | "url": "/note/new",
31 | "description": "Open a new note"
32 | }
33 | ],
34 | "categories": [
35 | "utilities"
36 | ]
37 | }
--------------------------------------------------------------------------------
/src/server/db/schema.ts:
--------------------------------------------------------------------------------
1 | // Example model schema from the Drizzle docs
2 | // https://orm.drizzle.team/docs/sql-schema-declaration
3 |
4 | import { sql } from "drizzle-orm";
5 | import { index, int, sqliteTableCreator, text } from "drizzle-orm/sqlite-core";
6 |
7 | /**
8 | * This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
9 | * database instance for multiple projects.
10 | *
11 | * @see https://orm.drizzle.team/docs/goodies#multi-project-schema
12 | */
13 | export const createTable = sqliteTableCreator((name) => `notes_${name}`);
14 |
15 | export const posts = createTable(
16 | "post",
17 | {
18 | id: int("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
19 | name: text("name", { length: 256 }),
20 | createdAt: int("created_at", { mode: "timestamp" })
21 | .default(sql`CURRENT_TIMESTAMP`)
22 | .notNull(),
23 | updatedAt: int("updatedAt", { mode: "timestamp" }),
24 | },
25 | (example) => ({
26 | nameIndex: index("name_idx").on(example.name),
27 | }),
28 | );
29 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Dhravya
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/app/ui/primitives/popover.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as PopoverPrimitive from "@radix-ui/react-popover";
5 | import { cn } from "@/lib/utils";
6 |
7 | const Popover = PopoverPrimitive.Root;
8 |
9 | const PopoverTrigger = PopoverPrimitive.Trigger;
10 |
11 | const PopoverContent = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef
14 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
15 |
16 |
26 |
27 | ));
28 | PopoverContent.displayName = PopoverPrimitive.Content.displayName;
29 |
30 | export { Popover, PopoverTrigger, PopoverContent };
31 |
--------------------------------------------------------------------------------
/src/components/skeletonLoader.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const SkeletonLoader = () => {
4 | // Generating some dummy data for the loader
5 | const dummyData = Array.from({ length: 5 }, (_, index) => ({
6 | key: `skeleton-${index}`,
7 | }));
8 |
9 | return (
10 |
11 |
12 |
13 | Your notes
14 |
15 |
16 | {dummyData.map(({ key }) => (
17 |
18 |
19 | Loading
20 |
21 |
22 |
23 | ))}
24 |
25 |
26 |
27 | );
28 | };
29 |
30 | export default SkeletonLoader;
31 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | /** @type {import("eslint").Linter.Config} */
2 | const config = {
3 | parser: "@typescript-eslint/parser",
4 | parserOptions: {
5 | project: true,
6 | },
7 | plugins: ["@typescript-eslint"],
8 | extends: [
9 | "next/core-web-vitals",
10 | "plugin:@typescript-eslint/recommended-type-checked",
11 | "plugin:@typescript-eslint/stylistic-type-checked",
12 | ],
13 | rules: {
14 | // These opinionated rules are enabled in stylistic-type-checked above.
15 | // Feel free to reconfigure them to your own preference.
16 | "@typescript-eslint/array-type": "off",
17 | "@typescript-eslint/consistent-type-definitions": "off",
18 |
19 | "@typescript-eslint/consistent-type-imports": [
20 | "warn",
21 | {
22 | prefer: "type-imports",
23 | fixStyle: "inline-type-imports",
24 | },
25 | ],
26 | "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
27 | "@typescript-eslint/require-await": "off",
28 | "@typescript-eslint/no-misused-promises": [
29 | "error",
30 | {
31 | checksVoidReturn: { attributes: false },
32 | },
33 | ],
34 | },
35 | ignorePatterns: ["next.config.mjs", ".eslintrc.cjs"],
36 | };
37 |
38 | module.exports = config;
39 |
--------------------------------------------------------------------------------
/src/app/api/upload/route.ts:
--------------------------------------------------------------------------------
1 | import { env } from "@/env";
2 | import { NextResponse } from "next/server";
3 |
4 | export const runtime = "edge";
5 |
6 | export async function POST(req: Request) {
7 | if (!process.env.BLOB_READ_WRITE_TOKEN) {
8 | return new Response(
9 | "Missing BLOB_READ_WRITE_TOKEN. Don't forget to add that to your .env file.",
10 | {
11 | status: 401,
12 | },
13 | );
14 | }
15 |
16 | const file = req.body ?? "";
17 | const filename = req.headers.get("x-vercel-filename") ?? "file.txt";
18 | const contentType = req.headers.get("content-type") ?? "text/plain";
19 | const fileType = `.${contentType.split("/")[1]}`;
20 |
21 | // construct final filename based on content-type if not provided
22 | const finalName = filename.includes(fileType)
23 | ? filename
24 | : `${filename}${fileType}`;
25 |
26 | const blob = await fetch("https://notty-images.dhravya.workers.dev", {
27 | method: "POST",
28 | headers: {
29 | "Content-Type": "application/json",
30 | "X-Custom-Auth-Key": `${env.CLOUDFLARE_R2_TOKEN}`,
31 | },
32 | body: JSON.stringify({
33 | filename: finalName,
34 | file,
35 | }),
36 | });
37 |
38 | const url = await blob.text();
39 |
40 | return NextResponse.json({
41 | success: true,
42 | message: "File uploaded successfully",
43 | data: url,
44 | });
45 | }
46 |
--------------------------------------------------------------------------------
/embedchain-backend/api/routes/admin.py:
--------------------------------------------------------------------------------
1 | import chromadb
2 | from chromadb.config import Settings
3 | from fastapi import APIRouter
4 |
5 | router = APIRouter()
6 |
7 |
8 | chroma_settings = Settings(
9 | anonymized_telemetry=False,
10 | persist_directory="db",
11 | allow_reset=False,
12 | is_persistent=True,
13 | )
14 | client = chromadb.Client(chroma_settings)
15 |
16 |
17 | @router.get("/api/v1/admin/collections")
18 | async def get_all_collections():
19 | # Currently only works for ChromaDB but can be extended easily
20 | # for other vector stores as well
21 | collections = client.list_collections()
22 | responses = [c.dict() for c in collections]
23 | return responses
24 |
25 |
26 | # TODO(deshraj): Add pagination and make this endpoint agnostic to the vector store
27 | @router.get("/api/v1/admin/collections/chromadb/embedchain_store")
28 | async def get_collection_details():
29 | collection = client.get_collection('embedchain_store')
30 | collection_data = collection.get()
31 | metadatas, documents = collection_data['metadatas'], collection_data['documents']
32 | collated_data = []
33 | for i in zip(metadatas, documents):
34 | collated_data.append({
35 | "metadata": i[0],
36 | "document": i[1]
37 | })
38 | response = {"details": collection.dict(), "data": collated_data}
39 | return response
40 |
--------------------------------------------------------------------------------
/src/app/api/fetchPosts/route.ts:
--------------------------------------------------------------------------------
1 | import { auth } from "@/lib/auth";
2 | import { env } from "@/env";
3 |
4 | export async function GET(_: Request): Promise {
5 | const user = await auth();
6 |
7 | if (!user?.user?.email) {
8 | return new Response(JSON.stringify({
9 | message: "Unauthorized",
10 | }), {
11 | status: 401,
12 | });
13 | }
14 |
15 | // save to cloudflare
16 | const getResponse = await fetch(
17 | `${env.WORKER_BASE_URL}?getAllFromUser=${user.user.email}`,
18 | {
19 | method: "GET",
20 | headers: {
21 | "X-Custom-Auth-Key": env.CLOUDFLARE_R2_TOKEN,
22 | },
23 | },
24 | );
25 |
26 | if (getResponse.status !== 200) {
27 | return new Response("Failed to get", {
28 | status: 500,
29 | });
30 | }
31 |
32 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
33 | const data = await getResponse.json();
34 |
35 | // Convert it into a list instead of an object
36 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
37 | const keys = Object.keys(data);
38 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
39 | const values = Object.values(data);
40 |
41 | // convert to list of [key, value] pairs
42 | const result = keys.map((key, index) => {
43 | return [key, values[index]];
44 | });
45 |
46 | return new Response(JSON.stringify(result), {
47 | status: 200,
48 | });
49 | }
50 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const config = {
4 | darkMode: ["class"],
5 | content: [
6 | "./pages/**/*.{ts,tsx}",
7 | "./components/**/*.{ts,tsx}",
8 | "./app/**/*.{ts,tsx}",
9 | "./src/**/*.{ts,tsx}",
10 | ],
11 | prefix: "",
12 | theme: {
13 | container: {
14 | center: true,
15 | padding: "2rem",
16 | screens: {
17 | "2xl": "1400px",
18 | },
19 | },
20 | extend: {
21 | fontFamily: {
22 | title: ["var(--font-title)", "system-ui", "sans-serif"],
23 | default: ["var(--font-default)", "system-ui", "sans-serif"],
24 | },
25 | keyframes: {
26 | "accordion-down": {
27 | from: { height: "0" },
28 | to: { height: "var(--radix-accordion-content-height)" },
29 | },
30 | "accordion-up": {
31 | from: { height: "var(--radix-accordion-content-height)" },
32 | to: { height: "0" },
33 | },
34 | gradient: {
35 | '0%': { 'background-position': '0% 50%' },
36 | '50%': { 'background-position': '100% 50%' },
37 | '100%': { 'background-position': '0% 50%' },
38 | }
39 | },
40 | animation: {
41 | "accordion-down": "accordion-down 0.2s ease-out",
42 | "accordion-up": "accordion-up 0.2s ease-out",
43 | 'gradient': 'gradient 3s linear infinite',
44 | },
45 | },
46 | },
47 | plugins: [require("tailwindcss-animate")],
48 | } satisfies Config;
49 |
50 | export default config;
51 |
--------------------------------------------------------------------------------
/src/components/search-results.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * This code was generated by v0 by Vercel.
3 | * @see https://v0.dev/t/5LWAfwVioH1
4 | */
5 | import Link from "next/link"
6 | import { CardContent, Card } from "@/components/ui/card"
7 | import { type AiResponse } from "@/types/aiResponse"
8 | import useNotes from "@/lib/context/NotesContext";
9 | import { extractTitle } from "@/lib/note";
10 |
11 | export function SearchResults({ aiResponse }: { aiResponse: AiResponse }) {
12 |
13 | const { kv } = useNotes();
14 |
15 | const getNoteTitle = (key: string) => {
16 | if (key.length === 10 && key.match(/^\d+$/)) {
17 | if (kv) {
18 | const value = kv.find(([k, _]) => k === key);
19 | if (value) {
20 | return extractTitle(value[1]);
21 | }
22 | }
23 | }
24 | return "Untitled";
25 | }
26 |
27 | return (
28 |
31 |
32 |
{aiResponse[0]}
33 |
✨ AI Search using Embedchain
34 |
35 |
36 | {aiResponse[1].map((value, index) => (
37 |
38 |
39 | {getNoteTitle(value[1].note_id)}
40 | {value[0]}
41 |
42 |
43 | Note {value[1].note_id}
44 |
45 |
46 |
47 |
48 | ))}
49 |
50 |
51 | )
52 | }
53 |
--------------------------------------------------------------------------------
/src/components/warning.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | /**
4 | * v0 by Vercel.
5 | * @see https://v0.dev/t/RvBL7CWvezj
6 | * Documentation: https://v0.dev/docs#integrating-generated-code-into-your-nextjs-app
7 | */
8 | import { Button } from "@/components/ui/button";
9 |
10 | export default function Warning({
11 | handleKeepLocalStorage,
12 | handleKeepCloudStorage,
13 | }: {
14 | handleKeepLocalStorage: () => void;
15 | handleKeepCloudStorage: () => void;
16 | }) {
17 | return (
18 |
22 |
23 |
24 |
Warning
25 |
26 | This note was fetched from local storage, it may not be the latest
27 | version.
28 |
29 |
30 |
31 |
36 | Keep Local Storage
37 |
38 |
43 | Keep Cloud
44 |
45 |
46 |
47 | );
48 | }
49 |
50 | function AlertTriangleIcon(props: React.SVGProps) {
51 | return (
52 |
64 |
65 |
66 |
67 |
68 | );
69 | }
70 |
--------------------------------------------------------------------------------
/src/app/ui/menu.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | Popover,
5 | PopoverTrigger,
6 | PopoverContent,
7 | } from "@/app/ui/primitives/popover";
8 | import { Check, Menu as MenuIcon, Monitor, Moon, SunDim } from "lucide-react";
9 | import { useTheme } from "next-themes";
10 |
11 | const appearances = [
12 | {
13 | theme: "System",
14 | icon: ,
15 | },
16 | {
17 | theme: "Light",
18 | icon: ,
19 | },
20 | {
21 | theme: "Dark",
22 | icon: ,
23 | },
24 | ];
25 |
26 | export default function Menu() {
27 | const { theme: currentTheme, setTheme } = useTheme();
28 |
29 | return (
30 |
31 |
32 |
33 |
34 |
35 |
36 |
Appearance
37 | {appearances.map(({ theme, icon }) => (
38 |
{
42 | setTheme(theme.toLowerCase());
43 | }}
44 | >
45 |
46 |
47 | {icon}
48 |
49 |
{theme}
50 |
51 | {currentTheme === theme.toLowerCase() && (
52 |
53 | )}
54 |
55 | ))}
56 |
57 |
58 |
59 | );
60 | }
61 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "notes",
3 | "version": "0.1.0",
4 | "private": true,
5 | "type": "module",
6 | "scripts": {
7 | "build": "next build",
8 | "db:push": "drizzle-kit push:sqlite",
9 | "db:studio": "drizzle-kit studio",
10 | "dev": "next dev",
11 | "lint": "next lint",
12 | "start": "next start"
13 | },
14 | "dependencies": {
15 | "@ducanh2912/next-pwa": "^10.2.4",
16 | "@formkit/auto-animate": "^0.8.1",
17 | "@radix-ui/react-icons": "^1.3.0",
18 | "@radix-ui/react-label": "^2.0.2",
19 | "@radix-ui/react-slot": "^1.0.2",
20 | "@t3-oss/env-core": "^0.8.0",
21 | "@t3-oss/env-nextjs": "^0.7.1",
22 | "@tailwindcss/typography": "^0.5.10",
23 | "@tiptap/html": "^2.2.1",
24 | "@types/next-pwa": "^5.6.9",
25 | "@upstash/ratelimit": "^1.0.0",
26 | "ai": "^2.2.32",
27 | "better-sqlite3": "^9.0.0",
28 | "class-variance-authority": "^0.7.0",
29 | "clsx": "^2.1.0",
30 | "darkreader": "^4.9.79",
31 | "drizzle-orm": "^0.29.3",
32 | "next": "^14.0.4",
33 | "next-auth": "beta",
34 | "next-pwa": "^5.6.0",
35 | "next-themes": "^0.2.1",
36 | "novel": "^0.1.22",
37 | "openai": "^4.26.0",
38 | "react": "18.2.0",
39 | "react-dom": "18.2.0",
40 | "react-loading-skeleton": "^3.4.0",
41 | "tailwind-merge": "^2.2.1",
42 | "tailwindcss-animate": "^1.0.7",
43 | "vaul": "^0.8.9",
44 | "zod": "^3.22.4"
45 | },
46 | "devDependencies": {
47 | "@types/better-sqlite3": "^7.6.6",
48 | "@types/eslint": "^8.44.7",
49 | "@types/node": "^18.17.0",
50 | "@types/react": "latest",
51 | "@types/react-dom": "^18.2.15",
52 | "@typescript-eslint/eslint-plugin": "^6.11.0",
53 | "@typescript-eslint/parser": "^6.11.0",
54 | "autoprefixer": "^10.4.14",
55 | "drizzle-kit": "^0.20.9",
56 | "eslint": "^8.54.0",
57 | "eslint-config-next": "^14.0.4",
58 | "postcss": "^8.4.31",
59 | "prettier": "^3.1.0",
60 | "prettier-plugin-tailwindcss": "^0.5.7",
61 | "tailwindcss": "^3.3.5",
62 | "typescript": "^5.1.6"
63 | },
64 | "ct3aMetadata": {
65 | "initVersion": "7.26.0"
66 | },
67 | "packageManager": "npm@10.2.4"
68 | }
69 |
--------------------------------------------------------------------------------
/src/components/NewNoteButton.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import Image from 'next/image'
4 | import { usePathname } from 'next/navigation'
5 |
6 |
7 | function NewNoteButton() {
8 | const pathname = usePathname()
9 |
10 | return (
11 | <>
12 | {pathname === '/' ? (
13 |
17 |
25 |
30 | {" "}
31 | New note
32 |
33 | ) : (
34 |
38 |
45 | notty
46 |
47 | )}
48 | >
49 | )
50 | }
51 |
52 | export default NewNoteButton
--------------------------------------------------------------------------------
/src/lib/note.ts:
--------------------------------------------------------------------------------
1 | import { type JSONContent } from "@tiptap/core";
2 |
3 | export const extractTitle = (value: JSONContent) => {
4 | let processedValue = value;
5 |
6 | if (typeof value === "string") {
7 | // convert into object
8 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
9 | processedValue = JSON.parse(value);
10 | }
11 |
12 | // Searching for the text inside the 'heading' type
13 | const contentArray = processedValue.content ?? [];
14 | for (const contentItem of contentArray) {
15 | if (!contentItem.content) {
16 | return "untitled";
17 | }
18 | for (const innerContent of contentItem.content) {
19 | const text = innerContent.text ?? "";
20 | return text.length > 36
21 | ? text.substring(0, 36) + "..."
22 | : text;
23 | }
24 | }
25 | return "untitled";
26 | };
27 |
28 | export const exportContentAsText = (value: JSONContent) => {
29 | let processedValue = value;
30 |
31 | if (typeof value === "string") {
32 | // convert into object
33 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
34 | processedValue = JSON.parse(value);
35 | }
36 |
37 | // Recursive function to find text in content
38 | const findText = (content: JSONContent): string[] => {
39 | let texts = [];
40 |
41 | // Base case: if the node itself is a text node, add its text to the array
42 | if (content.type === 'text') {
43 | texts.push(content.text ?? "");
44 | }
45 |
46 | // Check if the content has a 'content' property, which indicates further nesting
47 | if (content.content && Array.isArray(content.content)) {
48 | for (const child of content.content) {
49 | // Recursively call the function for each child content in the content array
50 | texts = texts.concat(findText(child));
51 | }
52 | }
53 |
54 | // Return all found text values
55 | return texts;
56 | }
57 |
58 | // Searching for the text inside the 'paragraph' type
59 | const contentArray = processedValue.content ?? [];
60 | const textArray = contentArray.flatMap(findText);
61 |
62 | // Skip the first line
63 | return textArray.slice(1).join("\n");
64 | }
--------------------------------------------------------------------------------
/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ))
18 | Card.displayName = "Card"
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ))
30 | CardHeader.displayName = "CardHeader"
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLParagraphElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
41 | ))
42 | CardTitle.displayName = "CardTitle"
43 |
44 | const CardDescription = React.forwardRef<
45 | HTMLParagraphElement,
46 | React.HTMLAttributes
47 | >(({ className, ...props }, ref) => (
48 |
53 | ))
54 | CardDescription.displayName = "CardDescription"
55 |
56 | const CardContent = React.forwardRef<
57 | HTMLDivElement,
58 | React.HTMLAttributes
59 | >(({ className, ...props }, ref) => (
60 |
61 | ))
62 | CardContent.displayName = "CardContent"
63 |
64 | const CardFooter = React.forwardRef<
65 | HTMLDivElement,
66 | React.HTMLAttributes
67 | >(({ className, ...props }, ref) => (
68 |
73 | ))
74 | CardFooter.displayName = "CardFooter"
75 |
76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
77 |
--------------------------------------------------------------------------------
/nottykv-cloudflare-worker/src/worker.ts:
--------------------------------------------------------------------------------
1 | interface Env {
2 | nottykv: KVNamespace;
3 | SECURITY_KEY: string;
4 | }
5 |
6 | const handleResponse = {
7 | async fetch(request: Request, env: Env) {
8 | if (!isAuthorized(request, env)) {
9 | return new Response('Unauthorized', { status: 401 });
10 | }
11 |
12 | const reqUrl = new URL(request.url);
13 | const key = reqUrl.searchParams.get('key');
14 | const getAllFromUser = reqUrl.searchParams.get('getAllFromUser');
15 |
16 | if (getAllFromUser) {
17 | return await getAllKeys(env, getAllFromUser);
18 | }
19 |
20 | if (!key) {
21 | return new Response('No key provided', { status: 400 });
22 | }
23 |
24 | switch (request.method) {
25 | case 'PUT':
26 | return await handlePut(request, env, key);
27 | case 'DELETE':
28 | return await handleDelete(env, key);
29 | default:
30 | return await handleGet(env, key);
31 | }
32 | },
33 | };
34 |
35 | function isAuthorized(request: Request, env: Env): boolean {
36 | return request.headers.get('X-Custom-Auth-Key') === env.SECURITY_KEY;
37 | }
38 |
39 | async function getAllKeys(env: Env, prefix: string): Promise {
40 | const keys = await env.nottykv.list({ prefix: prefix + '-' });
41 | const keyValuePairs: Record = {};
42 | for (const key of keys.keys) {
43 | keyValuePairs[key.name] = await env.nottykv.get(key.name);
44 | }
45 | return new Response(JSON.stringify(keyValuePairs));
46 | }
47 |
48 | async function handlePut(request: Request, env: Env, key: string): Promise {
49 | const body = await request.text();
50 | await env.nottykv.put(key, body);
51 | return new Response('OK');
52 | }
53 |
54 | async function handleDelete(env: Env, key: string): Promise {
55 | const data = await env.nottykv.get(key);
56 | if (!data) {
57 | return new Response('Not found', { status: 404 });
58 | }
59 | await env.nottykv.put('archived-' + key, data);
60 | await env.nottykv.delete(key);
61 | return new Response('OK');
62 | }
63 |
64 | async function handleGet(env: Env, key: string): Promise {
65 | const data = await env.nottykv.get(key);
66 | if (!data) {
67 | return new Response('Not found', { status: 404 });
68 | }
69 | return new Response(data);
70 | }
71 |
72 | export default handleResponse;
73 |
--------------------------------------------------------------------------------
/src/app/api/search/route.ts:
--------------------------------------------------------------------------------
1 | import { kv } from "@vercel/kv";
2 | import { Ratelimit } from "@upstash/ratelimit";
3 | import { env } from "@/env";
4 | import { auth } from "@/lib/auth";
5 | import { type AiResponse } from "@/types/aiResponse";
6 |
7 | export const runtime = "edge";
8 |
9 | export async function GET(req: Request): Promise {
10 |
11 | const user = await auth();
12 |
13 | if (!user?.user?.email) {
14 | return new Response("Login is required for this endpoint", {
15 | status: 401,
16 | });
17 | }
18 |
19 | if (
20 | env.KV_REST_API_URL &&
21 | env.KV_REST_API_TOKEN
22 | ) {
23 | const ip = req.headers.get("x-forwarded-for");
24 | const ratelimit = new Ratelimit({
25 | redis: kv,
26 | limiter: Ratelimit.slidingWindow(50, "1 d"),
27 | });
28 |
29 | const { success, limit, reset, remaining } = await ratelimit.limit(
30 | `notty_ratelimit_${ip}`,
31 | );
32 |
33 | if (!success) {
34 | return new Response("You have reached your request limit for the day.", {
35 | status: 429,
36 | headers: {
37 | "X-RateLimit-Limit": limit.toString(),
38 | "X-RateLimit-Remaining": remaining.toString(),
39 | "X-RateLimit-Reset": reset.toString(),
40 | },
41 | });
42 | }
43 | }
44 |
45 | // Get prompt from query
46 | const prompt = new URL(req.url).searchParams.get("prompt");
47 |
48 | if (!prompt) {
49 | return new Response("Invalid request", {
50 | status: 400,
51 | });
52 | }
53 |
54 | const aiResponse = await fetch(`${env.BACKEND_BASE_URL}/api/v1/search?query=${prompt}&user_id=${user.user.email}`, {
55 | method: "GET",
56 | headers: {
57 | "Authorization": `${env.CLOUDFLARE_R2_TOKEN}`
58 | }
59 | });
60 |
61 | if (aiResponse.status !== 200) {
62 | return new Response("Failed to get search results", {
63 | status: 500,
64 | });
65 | }
66 |
67 | const data = await aiResponse.json() as AiResponse;
68 |
69 | return new Response(JSON.stringify(data), {
70 | status: 200,
71 | });
72 |
73 | }
--------------------------------------------------------------------------------
/src/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 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-stone-950 disabled:pointer-events-none disabled:opacity-50 dark:focus-visible:ring-stone-300",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-stone-900 text-stone-50 shadow hover:bg-stone-900/90 dark:bg-stone-50 dark:text-stone-900 dark:hover:bg-stone-50/90",
14 | destructive:
15 | "bg-red-500 text-stone-50 shadow-sm hover:bg-red-500/90 dark:bg-red-900 dark:text-stone-50 dark:hover:bg-red-900/90",
16 | outline:
17 | "border border-stone-200 bg-white shadow-sm hover:bg-stone-100 hover:text-stone-900 dark:border-stone-800 dark:bg-stone-950 dark:hover:bg-stone-800 dark:hover:text-stone-50",
18 | secondary:
19 | "bg-stone-100 text-stone-900 shadow-sm hover:bg-stone-100/80 dark:bg-stone-800 dark:text-stone-50 dark:hover:bg-stone-800/80",
20 | ghost:
21 | "hover:bg-stone-100 hover:text-stone-900 dark:hover:bg-stone-800 dark:hover:text-stone-50",
22 | link: "text-stone-900 underline-offset-4 hover:underline dark:text-stone-50",
23 | },
24 | size: {
25 | default: "h-9 px-4 py-2",
26 | sm: "h-8 rounded-md px-3 text-xs",
27 | lg: "h-10 rounded-md px-8",
28 | icon: "h-9 w-9",
29 | },
30 | },
31 | defaultVariants: {
32 | variant: "default",
33 | size: "default",
34 | },
35 | },
36 | );
37 |
38 | export interface ButtonProps
39 | extends React.ButtonHTMLAttributes,
40 | VariantProps {
41 | asChild?: boolean;
42 | }
43 |
44 | const Button = React.forwardRef(
45 | ({ className, variant, size, asChild = false, ...props }, ref) => {
46 | const Comp = asChild ? Slot : "button";
47 | return (
48 |
53 | );
54 | },
55 | );
56 | Button.displayName = "Button";
57 |
58 | export { Button, buttonVariants };
59 |
--------------------------------------------------------------------------------
/src/env.js:
--------------------------------------------------------------------------------
1 | import { createEnv } from "@t3-oss/env-nextjs";
2 | import { z } from "zod";
3 |
4 | export const env = createEnv({
5 | /**
6 | * Specify your server-side environment variables schema here. This way you can ensure the app
7 | * isn't built with invalid env vars.
8 | */
9 | server: {
10 | DATABASE_URL: z
11 | .string()
12 | .refine(
13 | (str) => !str.includes("YOUR_MYSQL_URL_HERE"),
14 | "You forgot to change the default URL",
15 | ),
16 | NODE_ENV: z
17 | .enum(["development", "test", "production"])
18 | .default("development"),
19 | OPENROUTER_API_TOKEN: z.string(),
20 | KV_REST_API_URL: z.string(),
21 | KV_REST_API_TOKEN: z.string(),
22 | CLOUDFLARE_R2_TOKEN: z.string(),
23 | GOOGLE_CLIENT_ID: z.string(),
24 | GOOGLE_CLIENT_SECRET: z.string(),
25 | WORKER_BASE_URL: z.string(),
26 | BACKEND_BASE_URL: z.string(),
27 | },
28 |
29 | /**
30 | * Specify your client-side environment variables schema here. This way you can ensure the app
31 | * isn't built with invalid env vars. To expose them to the client, prefix them with
32 | * `NEXT_PUBLIC_`.
33 | */
34 | client: {
35 | // NEXT_PUBLIC_CLIENTVAR: z.string(),
36 | },
37 |
38 | /**
39 | * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
40 | * middlewares) or client-side so we need to destruct manually.
41 | */
42 | runtimeEnv: {
43 | DATABASE_URL: process.env.DATABASE_URL,
44 | NODE_ENV: process.env.NODE_ENV,
45 | OPENROUTER_API_TOKEN: process.env.OPENROUTER_API_TOKEN,
46 | KV_REST_API_URL: process.env.KV_REST_API_URL,
47 | KV_REST_API_TOKEN: process.env.KV_REST_API_TOKEN,
48 | CLOUDFLARE_R2_TOKEN: process.env.CLOUDFLARE_R2_TOKEN,
49 | GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
50 | GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
51 | WORKER_BASE_URL: process.env.WORKER_BASE_URL,
52 | BACKEND_BASE_URL: process.env.BACKEND_BASE_URL,
53 | // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
54 | },
55 | /**
56 | * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
57 | * useful for Docker builds.
58 | */
59 | skipValidation: !!process.env.SKIP_ENV_VALIDATION,
60 | /**
61 | * Makes it so that empty strings are treated as undefined.
62 | * `SOME_VAR: z.string()` and `SOME_VAR=''` will throw an error.
63 | */
64 | emptyStringAsUndefined: true,
65 | });
66 |
--------------------------------------------------------------------------------
/public/umami.js:
--------------------------------------------------------------------------------
1 | !function(){"use strict";!function(t){var e=t.screen,n=e.width,r=e.height,a=t.navigator.language,i=t.location,o=t.localStorage,c=t.document,u=t.history,s=i.hostname,f=i.pathname,l=i.search,d=c.currentScript;if(d){var v,p=function(t,e){return Object.keys(e).forEach((function(n){void 0!==e[n]&&(t[n]=e[n])})),t},h=function(t,e,n){var r=t[e];return function(){for(var e=[],a=arguments.length;a--;)e[a]=arguments[a];return n.apply(null,e),r.apply(t,e)}},m=function(){return o&&o.getItem("umami.disabled")||E&&function(){var e=t.doNotTrack,n=t.navigator,r=t.external,a="msTrackingProtectionEnabled",i=e||n.doNotTrack||n.msDoNotTrack||r&&a in r&&r[a]();return"1"==i||"yes"===i}()||A&&!N.includes(s)},g="data-",b="false",y=d.getAttribute.bind(d),w=y(g+"website-id"),S=y(g+"host-url"),k=y(g+"auto-track")!==b,E=y(g+"do-not-track"),T=y(g+"css-events")!==b,A=y(g+"domains")||"",N=A.split(",").map((function(t){return t.trim()})),j=(S?S.replace(/\/$/,""):d.src.split("/").slice(0,-1).join("/"))+"/api/collect",x=n+"x"+r,O=/^umami--([a-z]+)--([\w]+[\w-]*)$/,K="[class*='umami--']",L={},D=""+f+l,P=c.referrer,$=function(){return{website:w,hostname:s,screen:x,language:a,url:D}},_=function(t,e){var n;if(!m())return fetch(j,{method:"POST",body:JSON.stringify({type:t,payload:e}),headers:p({"Content-Type":"application/json"},(n={},n["x-umami-cache"]=v,n))}).then((function(t){return t.text()})).then((function(t){return v=t}))},q=function(t,e,n){return void 0===t&&(t=D),void 0===e&&(e=P),void 0===n&&(n=w),_("pageview",p($(),{website:n,url:t,referrer:e}))},z=function(t,e,n,r){return void 0===n&&(n=D),void 0===r&&(r=w),_("event",p($(),{website:r,url:n,event_name:t,event_data:e}))},C=function(t){var e=t.querySelectorAll(K);Array.prototype.forEach.call(e,I)},I=function(t){var e=t.getAttribute.bind(t);(e("class")||"").split(" ").forEach((function(n){if(O.test(n)){var r=n.split("--"),a=r[1],o=r[2],c=L[n]?L[n]:L[n]=function(n){"click"!==a||"A"!==t.tagName||n.ctrlKey||n.shiftKey||n.metaKey||n.button&&1===n.button||e("target")?z(o):(n.preventDefault(),z(o).then((function(){var t=e("href");t&&(i.href=t)})))};t.addEventListener(a,c,!0)}}))},J=function(t,e,n){if(n){P=D;var r=n.toString();(D="http"===r.substring(0,4)?"/"+r.split("/").splice(3).join("/"):r)!==P&&q()}};if(!t.umami){var M=function(t){return z(t)};M.trackView=q,M.trackEvent=z,t.umami=M}if(k&&!m()){u.pushState=h(u,"pushState",J),u.replaceState=h(u,"replaceState",J);var V=function(){"complete"===c.readyState&&(q(),T&&(C(c),new MutationObserver((function(t){t.forEach((function(t){var e=t.target;I(e),C(e)}))})).observe(c,{childList:!0,subtree:!0})))};c.addEventListener("readystatechange",V,!0),V()}}}(window)}();
--------------------------------------------------------------------------------
/src/components/DarkModeSwitch.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React, { useEffect, useState } from 'react'
4 |
5 | import {
6 | enable as enableDarkMode,
7 | disable as disableDarkMode,
8 | auto as followSystemColorScheme,
9 | isEnabled as isDarkReaderEnabled,
10 | } from 'darkreader';
11 |
12 | function DarkModeSwitch() {
13 |
14 | const [isDark, setIsDark] = useState(false);
15 |
16 | useEffect(() => {
17 | if (typeof window !== 'undefined') {
18 | followSystemColorScheme({
19 | sepia: 10,
20 | });
21 | if (isDarkReaderEnabled()) {
22 | setIsDark(true);
23 | }
24 | }
25 | }, [])
26 |
27 | return (
28 | {
31 | if (!isDarkReaderEnabled()) {
32 | enableDarkMode({
33 | sepia: 10,
34 | });
35 | setIsDark(true);
36 | } else {
37 | disableDarkMode();
38 | setIsDark(false);
39 | }
40 | }}
41 | >
42 | {isDark ? (
43 |
51 |
56 |
57 | ) : (
58 |
66 |
71 |
72 | )}
73 |
74 | )
75 | }
76 |
77 | export default DarkModeSwitch
--------------------------------------------------------------------------------
/src/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 | import { env } from "@/env";
6 |
7 | // Create an OpenAI API client (that's edge friendly!)
8 | const openai = new OpenAI({
9 | apiKey: env.OPENROUTER_API_TOKEN,
10 | // baseURL: "https://openrouter.ai/api/v1/",
11 | });
12 |
13 | // IMPORTANT! Set the runtime to edge: https://vercel.com/docs/functions/edge-functions/edge-runtime
14 | export const runtime = "edge";
15 |
16 | export async function POST(req: Request): Promise {
17 | if (
18 | env.KV_REST_API_URL &&
19 | env.KV_REST_API_TOKEN
20 | ) {
21 | const ip = req.headers.get("x-forwarded-for");
22 | const ratelimit = new Ratelimit({
23 | redis: kv,
24 | limiter: Ratelimit.slidingWindow(50, "1 d"),
25 | });
26 |
27 | const { success, limit, reset, remaining } = await ratelimit.limit(
28 | `notty_ratelimit_${ip}`,
29 | );
30 |
31 | if (!success) {
32 | return new Response("You have reached your request limit for the day.", {
33 | status: 429,
34 | headers: {
35 | "X-RateLimit-Limit": limit.toString(),
36 | "X-RateLimit-Remaining": remaining.toString(),
37 | "X-RateLimit-Reset": reset.toString(),
38 | },
39 | });
40 | }
41 | }
42 |
43 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
44 | const { prompt } = await req.json();
45 |
46 | const response = await openai.chat.completions.create({
47 | model: "gpt-3.5-turbo-1106",
48 | messages: [
49 | {
50 | role: "system",
51 | content:
52 | "You are an AI writing assistant that continues existing text based on context from prior text. " +
53 | "Give more weight/priority to the later characters than the beginning ones. " +
54 | "Limit your response to no more than 200 characters, but make sure to construct complete sentences. Just output in text format.",
55 | // we're disabling markdown for now until we can figure out a way to stream markdown text with proper formatting: https://github.com/steven-tey/novel/discussions/7
56 | // "Use Markdown formatting when appropriate.",
57 | },
58 | {
59 | role: "user",
60 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
61 | content: prompt,
62 | },
63 | ],
64 | temperature: 0.7,
65 | top_p: 1,
66 | frequency_penalty: 0,
67 | presence_penalty: 0,
68 | stream: true,
69 | n: 1,
70 | });
71 |
72 | // Convert the response into a friendly text-stream
73 | const stream = OpenAIStream(response);
74 |
75 | // Respond with the stream
76 | return new StreamingTextResponse(stream);
77 | }
78 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import "@/styles/globals.css";
4 |
5 | import { Inter } from "next/font/google";
6 | import Providers from "./providers";
7 | import { NotesViewer } from "./drawer";
8 | import NewNoteButton from "@/components/NewNoteButton";
9 | import Script from "next/script";
10 | import dynamic from 'next/dynamic'
11 |
12 | const DarkModeSwitch = dynamic(() => import('@/components/DarkModeSwitch'), { ssr: false })
13 |
14 | const inter = Inter({
15 | subsets: ["latin"],
16 | variable: "--font-sans",
17 | });
18 |
19 | export default function RootLayout({
20 | children,
21 | }: {
22 | children: React.ReactNode;
23 | }) {
24 | return (
25 |
26 |
27 |
33 | Notty
34 |
38 |
39 |
40 |
41 |
45 |
46 |
47 |
48 |
49 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 | {children}
72 |
73 |
74 |
75 | );
76 | }
77 |
--------------------------------------------------------------------------------
/nottykv-cloudflare-worker/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 |
3 | logs
4 | _.log
5 | npm-debug.log_
6 | yarn-debug.log*
7 | yarn-error.log*
8 | lerna-debug.log*
9 | .pnpm-debug.log*
10 |
11 | # Diagnostic reports (https://nodejs.org/api/report.html)
12 |
13 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
14 |
15 | # Runtime data
16 |
17 | pids
18 | _.pid
19 | _.seed
20 | \*.pid.lock
21 |
22 | # Directory for instrumented libs generated by jscoverage/JSCover
23 |
24 | lib-cov
25 |
26 | # Coverage directory used by tools like istanbul
27 |
28 | coverage
29 | \*.lcov
30 |
31 | # nyc test coverage
32 |
33 | .nyc_output
34 |
35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
36 |
37 | .grunt
38 |
39 | # Bower dependency directory (https://bower.io/)
40 |
41 | bower_components
42 |
43 | # node-waf configuration
44 |
45 | .lock-wscript
46 |
47 | # Compiled binary addons (https://nodejs.org/api/addons.html)
48 |
49 | build/Release
50 |
51 | # Dependency directories
52 |
53 | node_modules/
54 | jspm_packages/
55 |
56 | # Snowpack dependency directory (https://snowpack.dev/)
57 |
58 | web_modules/
59 |
60 | # TypeScript cache
61 |
62 | \*.tsbuildinfo
63 |
64 | # Optional npm cache directory
65 |
66 | .npm
67 |
68 | # Optional eslint cache
69 |
70 | .eslintcache
71 |
72 | # Optional stylelint cache
73 |
74 | .stylelintcache
75 |
76 | # Microbundle cache
77 |
78 | .rpt2_cache/
79 | .rts2_cache_cjs/
80 | .rts2_cache_es/
81 | .rts2_cache_umd/
82 |
83 | # Optional REPL history
84 |
85 | .node_repl_history
86 |
87 | # Output of 'npm pack'
88 |
89 | \*.tgz
90 |
91 | # Yarn Integrity file
92 |
93 | .yarn-integrity
94 |
95 | # dotenv environment variable files
96 |
97 | .env
98 | .env.development.local
99 | .env.test.local
100 | .env.production.local
101 | .env.local
102 |
103 | # parcel-bundler cache (https://parceljs.org/)
104 |
105 | .cache
106 | .parcel-cache
107 |
108 | # Next.js build output
109 |
110 | .next
111 | out
112 |
113 | # Nuxt.js build / generate output
114 |
115 | .nuxt
116 | dist
117 |
118 | # Gatsby files
119 |
120 | .cache/
121 |
122 | # Comment in the public line in if your project uses Gatsby and not Next.js
123 |
124 | # https://nextjs.org/blog/next-9-1#public-directory-support
125 |
126 | # public
127 |
128 | # vuepress build output
129 |
130 | .vuepress/dist
131 |
132 | # vuepress v2.x temp and cache directory
133 |
134 | .temp
135 | .cache
136 |
137 | # Docusaurus cache and generated files
138 |
139 | .docusaurus
140 |
141 | # Serverless directories
142 |
143 | .serverless/
144 |
145 | # FuseBox cache
146 |
147 | .fusebox/
148 |
149 | # DynamoDB Local files
150 |
151 | .dynamodb/
152 |
153 | # TernJS port file
154 |
155 | .tern-port
156 |
157 | # Stores VSCode versions used for testing VSCode extensions
158 |
159 | .vscode-test
160 |
161 | # yarn v2
162 |
163 | .yarn/cache
164 | .yarn/unplugged
165 | .yarn/build-state.yml
166 | .yarn/install-state.gz
167 | .pnp.\*
168 |
169 | # wrangler project
170 |
171 | .dev.vars
172 | .wrangler/
173 |
--------------------------------------------------------------------------------
/embedchain-backend/api/routes/api.py:
--------------------------------------------------------------------------------
1 | from embedchain import App
2 | from fastapi import APIRouter, responses
3 | from pydantic import BaseModel
4 | from dotenv import load_dotenv
5 |
6 | load_dotenv(".env")
7 |
8 | router = APIRouter()
9 |
10 | # App config using OpenAI gpt-3.5-turbo-1106 as LLM
11 | app_config = {
12 | "app": {
13 | "config": {
14 | "id": "notty-embeddings-app",
15 | }
16 | },
17 | "llm": {
18 | "provider": "openai",
19 | "config": {
20 | "model": "gpt-3.5-turbo-1106",
21 | },
22 | },
23 | }
24 |
25 | # Uncomment this configuration to use Mistral as LLM
26 | # app_config = {
27 | # "app": {
28 | # "config": {
29 | # "id": "embedchain-opensource-app"
30 | # }
31 | # },
32 | # "llm": {
33 | # "provider": "huggingface",
34 | # "config": {
35 | # "model": "mistralai/Mixtral-8x7B-Instruct-v0.1",
36 | # "temperature": 0.1,
37 | # "max_tokens": 250,
38 | # "top_p": 0.1
39 | # }
40 | # },
41 | # "embedder": {
42 | # "provider": "huggingface",
43 | # "config": {
44 | # "model": "sentence-transformers/all-mpnet-base-v2"
45 | # }
46 | # }
47 | # }
48 |
49 |
50 | ec_app = App.from_config(config=app_config)
51 |
52 |
53 | class SourceModel(BaseModel):
54 | source: str
55 | user: str
56 | note_id: str
57 |
58 |
59 | class QuestionModel(BaseModel):
60 | question: str
61 | session_id: str
62 |
63 |
64 | @router.post("/api/v1/add")
65 | async def add_source(source_model: SourceModel):
66 | """
67 | Adds a new source to the Embedchain app.
68 | Expects a JSON with a "source" key.
69 | """
70 | source = source_model.source
71 |
72 | ids = ec_app.db.get()
73 |
74 | doc_hash = None
75 | for meta_data in ids["metadatas"]:
76 | if (
77 | meta_data["note_id"] == source_model.note_id
78 | and meta_data["user"] == source_model.user
79 | ):
80 | doc_hash = meta_data["hash"]
81 | break
82 |
83 | if doc_hash:
84 | ec_app.delete(doc_hash)
85 |
86 | try:
87 | ec_app.add(
88 | source,
89 | metadata={"user": source_model.user, "note_id": source_model.note_id},
90 | )
91 | return {"message": f"Source '{source}' added successfully."}
92 | except Exception as e:
93 | response = f"An error occurred: Error message: {str(e)}."
94 | return {"message": response}
95 |
96 |
97 | @router.get("/api/v1/search")
98 | async def handle_search(query: str, user_id: str):
99 | """
100 | Handles a chat request to the Embedchain app.
101 | Accepts 'query' and 'session_id' as query parameters.
102 | """
103 | try:
104 | response = ec_app.query(query, citations=True, where={"user": {"$eq": user_id}})
105 | except Exception as e:
106 | response = f"An error occurred: Error message: {str(e)}" # noqa:E501
107 |
108 | return response
109 |
110 |
111 | @router.get("/")
112 | async def root():
113 | print("hi")
114 | return responses.RedirectResponse(url="/docs")
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | #
2 |
3 |
4 | Notty
5 |
6 |
7 |
8 | An open source, minimal AI powered note taking app and powerful markdown editor
9 |
10 |
11 | ## ✨ Features
12 |
13 | - **Simple**: Notty is designed to be extremely noise free and minimal, using it is a breeze.
14 | - **AI Powered**: Notty uses AI to help you write better notes and documents.
15 | - **Markdown**: Comes with a markdown editor built in, with WSIWYG functionality
16 | - **Cloud Sync**: Sync your notes across devices using the cloud
17 | - **Conflict Resolution**: If you use notty on multiple devices, it will automatically resolve conflicts for you, if not, it will prompt you to choose the correct version.
18 | - **Local-first**: Notty is designed to be local first, meaning your data is _always_ stored on your device, and optionally in the cloud.
19 | - **FAST**: Powered by Cloudflare KV, Notty is blazing fast.
20 |
21 | what more could you ask for?
22 |
23 | ## 🚀 Getting Started
24 |
25 | You can get started with notty by visiting [notty.dhr.wtf](https://notty.dhr.wtf)
26 |
27 | To set up locally, you can clone the repository and run the following commands:
28 |
29 | ```bash
30 | git clone https://github.com/dhravya/notty
31 | cd notty
32 | bun install
33 | bun run dev
34 | ```
35 |
36 | To run the cloudflare worker, you need to install wrangler, set up your cloudflare account and would also need to edit the `wrangler.toml` file to include your account id, zone ID, create bindings and add the necessary environment variables.
37 |
38 | ```bash
39 | wrangler dev
40 | ```
41 |
42 | The necessary environment variables are in the [`.env.example`](.env.example) file.
43 |
44 | ## 📚 Documentation
45 |
46 | The code is more or less self-explanatory and implementation details are documented as comments,
47 |
48 | ### Tech Stack
49 |
50 | - **Frontend**: Nextjs
51 | - **Backend**: Cloudflare Workers
52 | - **Database**: Cloudflare KV
53 | - **Caching**: Vercel KV
54 | - **AI**: OpenRouter API
55 | - **Editor**: [Novel](https://github.com/steventey/novel)
56 | - **Menu and UI**: [TailwindCSS](https://tailwindcss.com/) + [Vaul by Emil Kowalski](https://github.com/emilkowalski/vaul) + [Shadcn UI](https://ui.shadcn.com)
57 |
58 | ❤️ Thanks to all the open source projects that made this possible.
59 |
60 | ## TODO (Planned features)
61 |
62 | - [.] Fix delete button
63 | - [ ] Use a forked version of [Novel](https://github.com/steventey/novel) to add
64 | - [ ] Image upload (`/api/upload` route is already there, just need to send the req)
65 | - [ ] Background color of blocks
66 | - [ ] Dark mode (`next-themes` already there in [`src/app/providers.tsx`](src/app/providers.tsx), but commented out because styles are not yet implemented)
67 | - [.] Home page with list of all notes (google docs style) - currently `/` endpoint redirects to a random new note, that endpoint can be at `/new` and `/` can be the home page
68 |
69 | ## Future Features
70 |
71 | - [ ] Locked notes (requires [webauthn](https://github.com/nextauthjs/next-auth-webauthn)) maybe
72 | - [ ] Share notes and real time collab using [`partykit`](https://www.partykit.io/) maybe?
73 |
74 | ## 🤝 Contributing
75 |
76 | Contributions, issues and feature requests are welcome. Feel free to check the [issues page](/issues) if you want to contribute.
77 |
78 | ## 📝 License
79 |
80 | Notty is licensed under the MIT License. See [LICENSE](LICENSE) for more information.
81 |
--------------------------------------------------------------------------------
/src/app/note/[id]/novelEditor.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Warning from '@/components/warning';
4 | import useNotes from '@/lib/context/NotesContext';
5 | import { Editor } from 'novel';
6 | import { useEffect, useState } from 'react';
7 | import { type JSONContent } from "@tiptap/core"
8 | import defaultData from './defaultData';
9 |
10 | function NovelEditor({ id }: { id: string }) {
11 | const [data, setData] = useState('');
12 | const [cloudData, setCloudData] = useState('');
13 | const [syncWithCloudWarning, setSyncWithCloudWarning] = useState(false);
14 | const [saveStatus, setSaveStatus] = useState('Saved');
15 |
16 | const { revalidateNotes, kv } = useNotes();
17 |
18 | const loadData = async () => {
19 | try {
20 | const response = await fetch(`/api/note?id=${id}`);
21 |
22 | if (response.status === 404) {
23 | return null;
24 | }
25 | else if (!response.ok) {
26 | throw new Error('Network response was not ok');
27 | }
28 |
29 | const jsonData = await response.json() as JSONContent;
30 | return jsonData;
31 | } catch (error) {
32 | console.error('Error loading data from cloud:', error);
33 | return null;
34 | }
35 | };
36 |
37 | // Effect to synchronize data
38 | useEffect(() => {
39 | const synchronizeData = async () => {
40 | const cloud = await loadData();
41 | if (cloud) {
42 | setCloudData(cloud);
43 |
44 | const local = localStorage.getItem(id);
45 | if (local) {
46 | setData(local);
47 | if (local !== JSON.stringify(cloud)) {
48 | setSyncWithCloudWarning(true);
49 | }
50 | } else {
51 | setData(cloud);
52 | localStorage.setItem(id, JSON.stringify(cloud));
53 | }
54 | }
55 | };
56 |
57 | void synchronizeData();
58 | // eslint-disable-next-line react-hooks/exhaustive-deps
59 | }, [id]);
60 |
61 | const handleKeepLocalStorage = () => {
62 | setSyncWithCloudWarning(false);
63 | };
64 |
65 | const handleKeepCloudStorage = () => {
66 | localStorage.setItem(id, JSON.stringify(cloudData));
67 | setData(cloudData);
68 | setSyncWithCloudWarning(false);
69 | };
70 |
71 | return (
72 | <>
73 | {syncWithCloudWarning && (
74 |
78 | )}
79 |
80 |
81 | {saveStatus}
82 |
83 |
{
91 | setSaveStatus('Unsaved');
92 | }}
93 | onDebouncedUpdate={async (value) => {
94 | if (!value) return;
95 | const kvValue = kv.find(([key]) => key === id);
96 | const kvValueFirstLine = kvValue?.[1].content?.[0].content[0].text.split('\n')[0];
97 |
98 | // if first line edited, revalidate notes
99 | if (value.getText().split('\n')[0] !== kvValueFirstLine) {
100 | void revalidateNotes();
101 | }
102 |
103 | setSaveStatus('Saving...');
104 | const response = await fetch('/api/note', {
105 | method: 'POST',
106 | body: JSON.stringify({ id, data: value.getJSON() }),
107 | });
108 | const res = await response.text();
109 | setSaveStatus(res);
110 | }}
111 | />
112 |
113 | >
114 | );
115 | }
116 |
117 | export default NovelEditor;
118 |
--------------------------------------------------------------------------------
/src/app/api/note/route.ts:
--------------------------------------------------------------------------------
1 | import { env } from "@/env";
2 | import { auth } from "@/lib/auth";
3 | import { exportContentAsText } from "@/lib/note";
4 |
5 | export const runtime = "edge";
6 |
7 | export async function POST(req: Request): Promise {
8 | const user = await auth();
9 |
10 | if (!user?.user?.email) {
11 | return new Response("Saved locally | Login for Cloud Sync", {
12 | status: 401,
13 | });
14 | }
15 |
16 | // get request body
17 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
18 | const body = await req.json();
19 |
20 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
21 | const { id, data } = body;
22 |
23 | if (!id || !data) {
24 | return new Response("Invalid request", {
25 | status: 400,
26 | });
27 | }
28 |
29 | const key = `${user.user.email}-${id}`;
30 |
31 | // save to cloudflare
32 | const putResponse = await fetch(`${env.WORKER_BASE_URL}?key=${key}`, {
33 | method: "PUT",
34 | headers: {
35 | "Content-Type": "application/json",
36 | "X-Custom-Auth-Key": env.CLOUDFLARE_R2_TOKEN,
37 | },
38 | body: JSON.stringify(data),
39 | });
40 |
41 | if (putResponse.status !== 200) {
42 | return new Response("Failed to save", {
43 | status: 500,
44 | });
45 | }
46 |
47 | // Create embeddings using Embedchain
48 | try {
49 | const saveEmbedding = await fetch(`${env.BACKEND_BASE_URL}/api/v1/add`, {
50 | method: "POST",
51 | headers: {
52 | "Content-Type": "application/json",
53 | "Authorization": `${env.CLOUDFLARE_R2_TOKEN}`
54 | },
55 | body: JSON.stringify({
56 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
57 | source: exportContentAsText(data),
58 | user: user.user.email,
59 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
60 | note_id: id,
61 | }),
62 | });
63 |
64 | if (saveEmbedding.status !== 200) {
65 | console.error("Failed to save embedding");
66 | }
67 | } catch (error) {
68 | console.error("Error occurred while saving embedding: ", error);
69 | }
70 |
71 | return new Response("Saved", {
72 | status: 200,
73 | });
74 | }
75 |
76 | export async function GET(req: Request): Promise {
77 | const id = new URL(req.url).searchParams.get("id");
78 | const user = await auth();
79 |
80 | if (!user?.user?.email) {
81 | return new Response("Saved locally | Login for Cloud Sync", {
82 | status: 401,
83 | });
84 | }
85 | if (!id) {
86 | return new Response("Invalid request", {
87 | status: 400,
88 | });
89 | }
90 |
91 | const key = `${user.user.email}-${id}`;
92 |
93 | // save to cloudflare
94 | const getResponse = await fetch(`${env.WORKER_BASE_URL}?key=${key}`, {
95 | method: "GET",
96 | headers: {
97 | "X-Custom-Auth-Key": env.CLOUDFLARE_R2_TOKEN,
98 | },
99 | });
100 |
101 | if (getResponse.status !== 200) {
102 | return new Response(await getResponse.text(), {
103 | status: getResponse.status,
104 | });
105 | }
106 |
107 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
108 | const data = await getResponse.json();
109 |
110 | return new Response(JSON.stringify(data), {
111 | status: 200,
112 | });
113 | }
114 |
115 | export async function DELETE(req: Request): Promise {
116 | const id = new URL(req.url).searchParams.get("id");
117 | const user = await auth();
118 |
119 | if (!user?.user?.email) {
120 | return new Response("Saved locally | Login for Cloud Sync", {
121 | status: 401,
122 | });
123 | }
124 | if (!id) {
125 | return new Response("Invalid request", {
126 | status: 400,
127 | });
128 | }
129 |
130 | const key = `${user.user.email}-${id}`;
131 |
132 | // save to cloudflare
133 | const deleteResponse = await fetch(`${env.WORKER_BASE_URL}?key=${key}`, {
134 | method: "DELETE",
135 | headers: {
136 | "X-Custom-Auth-Key": env.CLOUDFLARE_R2_TOKEN,
137 | },
138 | });
139 |
140 | const data = await deleteResponse.text();
141 | console.log(data);
142 | if (deleteResponse.status !== 200) {
143 | return new Response(data, {
144 | status: 404,
145 | });
146 | }
147 |
148 | return new Response("Deleted", {
149 | status: 200,
150 | });
151 | }
152 |
--------------------------------------------------------------------------------
/src/lib/context/NotesContext.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React, { createContext, useContext, useState, useEffect } from 'react';
4 | import { type Value } from '@/types/note';
5 |
6 | type NotesContextValue = {
7 | kv: [string, Value][];
8 | loading: boolean;
9 | deleteNote: (keyToDelete: string) => Promise;
10 | revalidateNotes: () => Promise<[string, Value][]>;
11 | };
12 |
13 | const NotesContext = createContext(null);
14 |
15 | export const useNotes = () => {
16 | const contextValue = useContext(NotesContext);
17 |
18 | if (contextValue === null) {
19 | throw new Error("useNotes must be used within a NotesProvider");
20 | }
21 |
22 | return contextValue;
23 | };
24 |
25 | export const NotesProvider = ({ children }: { children: React.ReactNode }) => {
26 | const [kv, setKv] = useState<[string, Value][]>([]);
27 | const [loading, setLoading] = useState(true); // Loading state
28 |
29 |
30 | const fetchLocalStorageData = async () => {
31 | const entries = Object.entries(localStorage);
32 | const keyVal = entries
33 | .map(([key, value]: [key: string, value: string]) => {
34 | if (value && key.length === 10 && key.match(/^\d+$/)) {
35 | return [key, JSON.parse(value)] as [string, Value];
36 | }
37 | return undefined;
38 | })
39 | .filter((kv) => kv !== undefined);
40 |
41 | setKv(keyVal as [string, Value][]);
42 |
43 | return keyVal as [string, Value][];
44 | };
45 |
46 | // Function to fetch data from cloud
47 | const fetchCloudData = async () => {
48 | try {
49 | const response = await fetch("/api/fetchPosts");
50 | if (response.status != 200) {
51 | return [];
52 | }
53 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
54 | const data = await response.json();
55 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return
56 | return data as [string, Value][];
57 | } catch (error) {
58 | console.error("Error fetching cloud data:", error);
59 | return [];
60 | }
61 | };
62 |
63 | // Function to combine and set data from both sources
64 | const combineData = async () => {
65 | setLoading(true); // Set loading state to true when data fetching starts
66 | const [localData, cloudData] = await Promise.all([fetchLocalStorageData(), fetchCloudData()]);
67 | // Process cloud data to match local data format
68 | const processedCloudData = cloudData?.map(
69 | ([key, value]: [key: string, value: Value]) => {
70 | const id = key.split("-").pop(); // Extracts the id from [email]-id format
71 | return [id, value] as [string, Value];
72 | },
73 | );
74 |
75 | const newData = [...localData, ...processedCloudData]
76 | .filter(([_, value]: [string, Value]) => {
77 | return value !== null;
78 | })
79 | .sort((a, b) => {
80 | return Number(b[0]) - Number(a[0]);
81 | });
82 |
83 | const uniqueKeys = Array.from(new Set(newData.map(([key, _]) => key)));
84 |
85 | const uniqueData = uniqueKeys.map((key) => {
86 | return newData.find(([k, _]) => k === key)!;
87 | });
88 |
89 | // Combine and set data
90 | setKv(uniqueData)
91 | setLoading(false); // Set loading state to false when data fetching is complete
92 |
93 | return kv;
94 | };
95 |
96 |
97 | useEffect(() => {
98 | void combineData();
99 | // eslint-disable-next-line react-hooks/exhaustive-deps
100 | }, []);
101 |
102 | const deleteNote = async (keyToDelete: string) => {
103 | const newKey = "archived-" + keyToDelete;
104 | const newValue = localStorage.getItem(keyToDelete);
105 | localStorage.removeItem(keyToDelete);
106 | localStorage.setItem(newKey, JSON.stringify(newValue));
107 |
108 | try {
109 | await fetch(`/api/note?id=${keyToDelete}`, {
110 | method: "DELETE",
111 | headers: {
112 | "Content-Type": "application/json",
113 | },
114 | });
115 | } catch (error) {
116 | console.error("Error deleting note:", error);
117 | }
118 | void combineData();
119 | };
120 |
121 | const revalidateNotes = async () => {
122 | return await combineData();
123 | }
124 |
125 | return (
126 |
127 | {children}
128 |
129 | );
130 | };
131 |
132 | export default useNotes;
133 |
--------------------------------------------------------------------------------
/src/app/note/[id]/defaultData.ts:
--------------------------------------------------------------------------------
1 | const defaultData = { "type": "doc", "content": [{ "type": "heading", "attrs": { "level": 2 }, "content": [{ "type": "text", "text": "Welcome to Notty notes! (OPEN ME)" }, { "type": "text", "marks": [{ "type": "code" }], "text": "notty.dhr.wtf" }] }, { "type": "heading", "attrs": { "level": 3 }, "content": [{ "type": "text", "text": "⚡ Features" }] }, { "type": "paragraph", "content": [{ "type": "text", "marks": [{ "type": "textStyle", "attrs": { "color": "" } }], "text": "Notty is " }, { "type": "text", "text": "an " }, { "type": "text", "marks": [{ "type": "italic" }], "text": "AI-powered notes app " }, { "type": "text", "text": "built for " }, { "type": "text", "marks": [{ "type": "bold" }, { "type": "textStyle", "attrs": { "color": "#2563EB" } }], "text": "productivity and speed" }, { "type": "text", "marks": [{ "type": "textStyle", "attrs": { "color": "rgb(37, 99, 235)" } }], "text": ". " }, { "type": "text", "text": "Type " }, { "type": "text", "marks": [{ "type": "code" }], "text": "/" }, { "type": "text", "text": " for commands, and start typing with " }, { "type": "text", "marks": [{ "type": "code" }], "text": "++" }, { "type": "text", "text": " for the " }, { "type": "text", "marks": [{ "type": "italic" }], "text": "\"Continue writing\"" }, { "type": "text", "text": " feature. " }] }, { "type": "paragraph", "content": [{ "type": "text", "text": "You can also " }, { "type": "text", "marks": [{ "type": "italic" }], "text": "Talk to your notes or search using AI" }, { "type": "text", "text": ", but you need to " }, { "type": "text", "marks": [{ "type": "link", "attrs": { "href": "https://notty.dhr.wtf/api/auth/signin/google", "target": "_blank", "rel": "noopener noreferrer nofollow", "class": "novel-text-stone-400 novel-underline novel-underline-offset-[3px] hover:novel-text-stone-600 novel-transition-colors novel-cursor-pointer" } }, { "type": "textStyle", "attrs": { "color": "#2563EB" } }], "text": "sign in" }, { "type": "text", "marks": [{ "type": "textStyle", "attrs": { "color": "#2563EB" } }], "text": " " }, { "type": "text", "text": "for that first. Then, go to your home page and see the magic happen! ✨ " }] }, { "type": "paragraph", "content": [{ "type": "text", "text": "Many of these AI features are made with " }, { "type": "text", "marks": [{ "type": "link", "attrs": { "href": "https://embedchain.ai", "target": "_blank", "rel": "noopener noreferrer nofollow", "class": "novel-text-stone-400 novel-underline novel-underline-offset-[3px] hover:novel-text-stone-600 novel-transition-colors novel-cursor-pointer" } }], "text": "Embedchain" }] }, { "type": "heading", "attrs": { "level": 3 }, "content": [{ "type": "text", "text": "❤️ Support" }] }, { "type": "paragraph", "content": [{ "type": "text", "text": "BTW - Notty is open source. here's the link - " }, { "type": "text", "marks": [{ "type": "link", "attrs": { "href": "https://github.com/dhravya/notty", "target": "_blank", "rel": "noopener noreferrer nofollow", "class": "novel-text-stone-400 novel-underline novel-underline-offset-[3px] hover:novel-text-stone-600 novel-transition-colors novel-cursor-pointer" } }], "text": "https://github.com/dhravya/notty" }, { "type": "text", "text": ". A ⭐ star would be" }, { "type": "text", "marks": [{ "type": "italic" }], "text": " really appreciated." }] }, { "type": "paragraph", "content": [{ "type": "text", "text": "It costs to run this app, so if you " }, { "type": "text", "marks": [{ "type": "italic" }], "text": "really" }, { "type": "text", "text": " like notty, you can " }, { "type": "text", "marks": [{ "type": "link", "attrs": { "href": "https://github.com/sponsors/Dhravya", "target": "_blank", "rel": "noopener noreferrer nofollow", "class": "novel-text-stone-400 novel-underline novel-underline-offset-[3px] hover:novel-text-stone-600 novel-transition-colors novel-cursor-pointer" } }], "text": "Sponsor me on Github" }, { "type": "text", "text": " or " }, { "type": "text", "marks": [{ "type": "link", "attrs": { "href": "https://ko-fi.com/dhravya", "target": "_blank", "rel": "noopener noreferrer nofollow", "class": "novel-text-stone-400 novel-underline novel-underline-offset-[3px] hover:novel-text-stone-600 novel-transition-colors novel-cursor-pointer" } }], "text": "Buy me a Coffee" }, { "type": "text", "text": " or just tweet about us." }] }, { "type": "heading", "attrs": { "level": 3 }, "content": [{ "type": "text", "text": "⭐ Credit" }] }, { "type": "paragraph", "content": [{ "type": "text", "text": "This project, like many others has been built on many amazing open source projects. " }, { "type": "hardBreak" }, { "type": "text", "text": "Thanks to" }] }, { "type": "bulletList", "attrs": { "tight": true }, "content": [{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "marks": [{ "type": "link", "attrs": { "href": "https://vaul.emilkowal.ski/", "target": "_blank", "rel": "noopener noreferrer nofollow", "class": "novel-text-stone-400 novel-underline novel-underline-offset-[3px] hover:novel-text-stone-600 novel-transition-colors novel-cursor-pointer" } }], "text": "https://vaul.emilkowal.ski/" }] }] }, { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "marks": [{ "type": "link", "attrs": { "href": "https://embedchain.ai", "target": "_blank", "rel": "noopener noreferrer nofollow", "class": "novel-text-stone-400 novel-underline novel-underline-offset-[3px] hover:novel-text-stone-600 novel-transition-colors novel-cursor-pointer" } }], "text": "https://embedchain.ai" }, { "type": "text", "text": " " }] }] }, { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "marks": [{ "type": "link", "attrs": { "href": "https://github.com/steven-tey/novel", "target": "_blank", "rel": "noopener noreferrer nofollow", "class": "novel-text-stone-400 novel-underline novel-underline-offset-[3px] hover:novel-text-stone-600 novel-transition-colors novel-cursor-pointer" } }], "text": "https://github.com/steven-tey/novel" }] }] }] }] }
2 |
3 | export default defaultData;
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import useNotes from "@/lib/context/NotesContext";
4 | import { exportContentAsText, extractTitle } from "@/lib/note";
5 | import { type Value } from "@/types/note";
6 | import Image from "next/image";
7 | import Link from "next/link";
8 | import { CardTitle, CardHeader, CardContent, Card } from "@/components/ui/card"
9 | import { Input } from "@/components/ui/input"
10 | import { Label } from "@/components/ui/label"
11 | import { Button } from "@/components/ui/button"
12 | import { useEffect, useState } from "react";
13 | import { type AiResponse, aiResponse } from "@/types/aiResponse";
14 | import { SearchResults } from "@/components/search-results";
15 | import { useSession } from "next-auth/react";
16 | import defaultData from "./note/[id]/defaultData";
17 |
18 |
19 | export default function HomePage() {
20 | const { kv, deleteNote } = useNotes();
21 | const [searchQuery, setSearchQuery] = useState("");
22 | const [searchResults, setSearchResults] = useState(null);
23 | const [isAiLoading, setIsAiLoading] = useState(false);
24 |
25 | const { data: session } = useSession()
26 |
27 | const getSearchResults = async () => {
28 | if (searchQuery) {
29 | setIsAiLoading(true);
30 | const response = await fetch(`/api/search?prompt=${searchQuery}`, {
31 | method: "GET",
32 | headers: {
33 | "Content-Type": "application/json",
34 | },
35 | });
36 | const data = aiResponse.safeParse(await response.json());
37 |
38 | if (data.success) {
39 | console.log(data.data);
40 | setSearchResults(data.data);
41 | }
42 |
43 | console.log(data);
44 | setIsAiLoading(false);
45 | }
46 | }
47 |
48 | useEffect(() => {
49 | if (!localStorage.getItem('archived-1000000001') && !localStorage.getItem('1000000001')) {
50 | localStorage.setItem('1000000001', JSON.stringify(defaultData));
51 | }
52 | }, []);
53 |
54 | return (
55 |
56 |
57 |
58 |
59 |
60 |
Notty
61 | A simple, minimal AI powered note taking app and markdown editor - Built local-first, with cloud sync. Also has AI features so you can focus on writing.
62 |
63 |
64 | {session?.user?.email && (
65 |
78 | )}
79 |
80 |
81 | {/* TODO: FIX GRADIENT BACKGROUND ANIMATION */}
82 | {isAiLoading && (
83 |
87 |
Loading Results...
88 |
89 | )}
90 | {searchResults && (
91 |
92 | )}
93 |
94 | {kv && (
95 |
96 |
Your Notes
97 |
98 | {kv.map(([key, value]: [string, Value]) => (
99 |
104 |
105 |
106 |
107 | {key.length === 10 && key.match(/^\d+$/)
108 | ? value
109 | ? extractTitle(value)
110 | : "untitled"
111 | : null}
112 |
113 |
114 |
115 |
116 | {exportContentAsText(value)}
117 |
118 |
119 |
120 |
121 |
{
124 | e.preventDefault()
125 | await deleteNote(key);
126 | }}
127 | >
128 |
134 |
139 |
140 |
141 |
142 |
143 |
144 |
145 | ))}
146 |
147 |
148 | )}
149 |
150 | );
151 | }
152 |
--------------------------------------------------------------------------------
/src/app/drawer.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Drawer } from "vaul";
4 | import { useEffect, useRef } from "react";
5 | import autoAnimate from "@formkit/auto-animate";
6 | import { signIn, signOut, useSession } from "next-auth/react";
7 | import { type Value } from "@/types/note";
8 | import { extractTitle } from "@/lib/note";
9 | import useNotes from "@/lib/context/NotesContext";
10 | import SkeletonLoader from "@/components/skeletonLoader";
11 | import Image from "next/image";
12 |
13 |
14 | const ResponsiveDrawer = ({ children }: { children: React.ReactNode }) => (
15 |
16 |
17 |
18 | {children}
19 |
20 |
21 |
22 |
23 | {children}
24 |
25 |
26 |
27 | );
28 | export function NotesViewer() {
29 | const { data: session } = useSession();
30 |
31 | const { kv, deleteNote, loading } = useNotes();
32 |
33 | const parent = useRef(null);
34 |
35 | useEffect(() => {
36 | parent.current && autoAnimate(parent.current);
37 | }, [parent]);
38 |
39 | return (
40 |
41 |
45 |
46 |
54 |
59 |
60 | Menu
61 |
62 |
63 |
64 |
65 |
66 | <>
67 |
68 |
69 |
70 | {session?.user?.email ? (
71 |
72 |
73 |
81 |
82 |
Signed in as
83 |
84 | {session.user.name}
85 |
86 |
87 |
88 |
signOut()}>
89 | Logout
90 |
91 |
92 | ) : (
93 |
94 | signIn('google')}>
95 | Login with Google
96 |
97 |
98 | )}
99 | {loading ?
: (
100 |
101 |
102 | Your notes
103 |
104 |
105 | {kv.map(([key, value]: [string, Value]) => (
106 |
137 | ))}
138 |
139 |
140 | )}
141 |
142 |
143 |
144 |
149 |
150 |
151 |
199 |
200 | >
201 |
202 |
203 |
204 | );
205 | }
206 |
--------------------------------------------------------------------------------