├── .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 | 38 | 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 | 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 | logo 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 | 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 | Notty is a simple, minimal AI powered note taking app and markdown editor 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 | logo 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 |
e.preventDefault()} 67 | > 68 |
69 | 70 |
71 | setSearchQuery(e.target.value)} 73 | placeholder="Search using AI... ✨" id='searchInput' /> 74 | 75 |
76 |
77 |
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 | 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 | 62 | 63 | 64 | 65 | 66 | <> 67 |
68 |
69 |
70 | {session?.user?.email ? ( 71 |
72 |
73 | profile 81 |
82 |

Signed in as

83 |

84 | {session.user.name} 85 |

86 |
87 |
88 | 91 |
92 | ) : ( 93 |
94 | 97 |
98 | )} 99 | {loading ? : ( 100 |
101 | 102 | Your notes 103 | 104 |
105 | {kv.map(([key, value]: [string, Value]) => ( 106 |
107 | 111 | {key.length === 10 && key.match(/^\d+$/) 112 | ? value 113 | ? extractTitle(value) 114 | : "untitled" 115 | : null} 116 | 117 | 136 |
137 | ))} 138 |
139 |
140 | )} 141 |
142 |
143 |
144 |
147 | Made with ❤️ by Dhravya Shah 148 |
149 | 150 | 151 | 199 |
200 | 201 | 202 | 203 | 204 | ); 205 | } 206 | --------------------------------------------------------------------------------