├── .env.example ├── .eslintrc.json ├── .gitignore ├── README.md ├── next.config.mjs ├── package-lock.json ├── package.json ├── pnpm-lock.yaml ├── postcss.config.cjs ├── prettier.config.cjs ├── prisma └── schema.prisma ├── public └── images │ ├── editor-light.png │ ├── editor-mobile-light.jpg │ ├── logos │ ├── fa.svg │ ├── headless-ui.svg │ ├── nextjs-dark.svg │ ├── postgresql.png │ ├── prisma.jpg │ ├── prisma.png │ ├── react-query.svg │ ├── tailwindcss.svg │ └── zod.svg │ ├── notes-light.png │ ├── notes-mobile-light.jpg │ └── preview-mobile-light.jpg ├── src ├── components │ ├── Button.tsx │ ├── CaretUpDownIcon.tsx │ ├── Layouts │ │ ├── Modal.tsx │ │ └── Page.tsx │ ├── LoadingNoteCard.tsx │ ├── Modals │ │ ├── CreateNote.tsx │ │ ├── DeleteNote.tsx │ │ ├── ManageTags.tsx │ │ └── NoteOptions.tsx │ ├── Navbar.tsx │ ├── NoteCard.tsx │ ├── SearchBar.tsx │ ├── TagPill.tsx │ └── Tooltip.tsx ├── env │ ├── client.mjs │ ├── schema.mjs │ └── server.mjs ├── hooks │ └── useHasHydrated.tsx ├── index.d.ts ├── pages │ ├── 404.tsx │ ├── _app.tsx │ ├── api │ │ ├── auth │ │ │ └── [...nextauth].ts │ │ └── trpc │ │ │ └── [trpc].ts │ ├── auth │ │ └── signin.tsx │ ├── index.tsx │ └── notes │ │ ├── [id].tsx │ │ └── index.tsx ├── prisma-testing.ts ├── server │ ├── api │ │ ├── root.ts │ │ ├── routers │ │ │ ├── example.ts │ │ │ ├── notes.ts │ │ │ └── tags.ts │ │ └── trpc.ts │ ├── auth.ts │ └── db.ts ├── stores │ ├── search.ts │ └── theme.ts ├── styles │ ├── globals.css │ └── markdown.module.css └── utils │ ├── api.ts │ └── dates.ts ├── tailwind.config.cjs └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # Since the ".env" file is gitignored, you can use the ".env.example" file to 2 | # build a new ".env" file when you clone the repo. Keep this file up-to-date 3 | # when you add new variables to `.env`. 4 | 5 | # This file will be committed to version control, so make sure not to have any 6 | # secrets in it. If you are cloning this repo, create a copy of this file named 7 | # ".env" and populate it with your secrets. 8 | 9 | # When adding additional environment variables, the schema in "/env/schema.mjs" 10 | # should be updated accordingly. 11 | 12 | # Prisma 13 | # https://www.prisma.io/docs/reference/database-reference/connection-urls#env 14 | DATABASE_URL="file:./db.sqlite" 15 | 16 | # Next Auth 17 | # You can generate a new secret on the command line with: 18 | # openssl rand -base64 32 19 | # https://next-auth.js.org/configuration/options#secret 20 | # NEXTAUTH_SECRET="" 21 | NEXTAUTH_URL="http://localhost:3000" 22 | 23 | # Next Auth Providers 24 | DISCORD_CLIENT_ID="" 25 | DISCORD_CLIENT_SECRET="" 26 | GOOGLE_CLIENT_ID="" 27 | GOOGLE_CLIENT_SECRET="" 28 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "overrides": [ 3 | { 4 | "extends": [ 5 | "plugin:@typescript-eslint/recommended-requiring-type-checking" 6 | ], 7 | "files": ["*.ts", "*.tsx"], 8 | "parserOptions": { 9 | "project": "tsconfig.json" 10 | } 11 | } 12 | ], 13 | "parser": "@typescript-eslint/parser", 14 | "parserOptions": { 15 | "project": "./tsconfig.json" 16 | }, 17 | "plugins": ["@typescript-eslint"], 18 | "extends": ["next/core-web-vitals", "plugin:@typescript-eslint/recommended"], 19 | "rules": { 20 | "@typescript-eslint/consistent-type-imports": "warn" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # database 12 | /prisma/db.sqlite 13 | /prisma/db.sqlite-journal 14 | 15 | # next.js 16 | /.next/ 17 | /out/ 18 | next-env.d.ts 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # local env files 34 | # 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 35 | .env 36 | .env*.local 37 | 38 | # vercel 39 | .vercel 40 | 41 | # typescript 42 | *.tsbuildinfo 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LuccaNotes 2 | 3 | A full-stack note-taking app made by Lucca Rodrigues for fellow lovers of Markdown. 💙 4 | 5 | Built with the awesome [T3 stack](https://create.t3.gg/) for [Next.js](https://nextjs.org/), deployed on [Supabase](https://supabase.com/) and [Vercel](https://vercel.com/). 6 | 7 | [LuccaNotes is now live!](https://luccanotes.vercel.app/) Go check it out. 8 | 9 | ![LuccaNotes landing page](https://blaring.net/images/portfolio/luccanotes/landing-page.png) 10 | 11 | ## Features 12 | 13 | Here are just a few of LuccaNotes' awesome features: 14 | 15 | - **GitHub Flavored Markdown**: The best flavor of Markdown! The GFM spec is supported by LuccaNotes' text editor and Markdown renderer for note previews. 16 | - **Auto-saving**: Changes to your notes are automatically saved to LuccaNotes' backend, meaning you'll never need to worry about losing your work. 17 | - **Tags keep notes tidy**: Our tagging system allows you to effortlessly group, organize, and search through your notes - regardless if you have 5 or 5000! 18 | - **Note previews**: The toggleable Markdown preview displays a fully rendered version of your note's content as you type it out in the text editor. 19 | - **Keyboard navigation & a11y**: LuccaNotes is built with full accessibility in mind. In addition to a more inclusive UX, this allows for speedy keyboard navigation throughout the entire app. 20 | - **Sort, Filter & Search**: A sensible and easy-to-use search tool lets you quickly browse your collection and find the note you're looking for. It's as simple as that! 21 | 22 | ## Local development 23 | 24 | **Prerequisites**: Make sure [Node.js](https://nodejs.org/) and [PostgreSQL](https://www.postgresql.org/) are installed on your system. 25 | 26 | - Clone and `cd` into this repo. 27 | ``` 28 | $ git clone https://github.com/ChromeUniverse/luccanotes.git 29 | $ cd luccanotes 30 | ``` 31 | - Install dependencies: 32 | ``` 33 | npm i 34 | ``` 35 | - Register new OAuth2 apps with Google and Discord. See [NextAuth docs on Authentication Providers](https://next-auth.js.org/providers/) for more information. 36 | - Create a new `.env` file by copying and pasting `.env.example`, then populate it with your environment variables, including Prisma connection string, OAuth2 credentials and NextAuth secret. 37 | - Create a new PostgreSQL database for this app and push the Prisma schema. 38 | ``` 39 | npx prisma db push 40 | ``` 41 | - Start the local development server. 42 | ``` 43 | npm run dev 44 | ``` 45 | 46 | ## Deploying 47 | 48 | LuccaNotes' live demo is deployed on Vercel (Next.js app) and Supabase (PostgreSQL database), but any hosting services with support for Next.js and/or PostgreSQL should work fine. 49 | 50 | **First, set up your database.** To deploy on Supabase, first create a new project. Once it's set up, go to Project Settings -> Database -> Connection String. Copy the Node.js connection string and temporarily change it to your local `.env`, then push your Prisma schema by running `npx prisma db push` locally. 51 | 52 | **Now, the Next.js app.** To deploy on Vercel, simply visit your [dashboard](https://vercel.com/dashboard), select your GitHub repo, set up your environment variables, and voilà! -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. 5 | * This is especially useful for Docker builds. 6 | */ 7 | !process.env.SKIP_ENV_VALIDATION && (await import("./src/env/server.mjs")); 8 | 9 | /** @type {import("next").NextConfig} */ 10 | const config = { 11 | reactStrictMode: true, 12 | 13 | /** 14 | * If you have the "experimental: { appDir: true }" setting enabled, then you 15 | * must comment the below `i18n` config out. 16 | * 17 | * @see https://github.com/vercel/next.js/issues/41980 18 | */ 19 | i18n: { 20 | locales: ["en"], 21 | defaultLocale: "en", 22 | }, 23 | }; 24 | export default config; 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "luccanotes", 3 | "version": "0.1.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "build": "next build", 8 | "dev": "next dev", 9 | "postinstall": "prisma generate", 10 | "lint": "next lint", 11 | "start": "next start", 12 | "db:test": "tsx src/prisma-testing.ts" 13 | }, 14 | "dependencies": { 15 | "@codemirror/lang-markdown": "^6.1.0", 16 | "@codemirror/language-data": "^6.1.0", 17 | "@codemirror/state": "^6.2.0", 18 | "@codemirror/view": "^6.9.1", 19 | "@fortawesome/fontawesome-svg-core": "^6.3.0", 20 | "@fortawesome/free-brands-svg-icons": "^6.3.0", 21 | "@fortawesome/react-fontawesome": "^0.2.0", 22 | "@headlessui/react": "^1.7.9", 23 | "@headlessui/tailwindcss": "^0.1.2", 24 | "@next-auth/prisma-adapter": "^1.0.5", 25 | "@next/font": "13.1.6", 26 | "@prisma/client": "^5.11.0", 27 | "@tanstack/react-query": "^4.20.0", 28 | "@trpc/client": "^10.9.0", 29 | "@trpc/next": "^10.9.0", 30 | "@trpc/react-query": "^10.9.0", 31 | "@trpc/server": "^10.9.0", 32 | "@uiw/codemirror-theme-aura": "^4.19.9", 33 | "@uiw/codemirror-theme-tokyo-night": "^4.19.9", 34 | "@uiw/codemirror-theme-tokyo-night-day": "^4.19.9", 35 | "@uiw/react-codemirror": "^4.19.9", 36 | "class-variance-authority": "^0.4.0", 37 | "diff-match-patch": "^1.0.5", 38 | "nanoid": "^4.0.1", 39 | "next": "13.1.6", 40 | "next-auth": "^4.19.0", 41 | "phosphor-react": "^1.4.1", 42 | "react": "18.2.0", 43 | "react-dom": "18.2.0", 44 | "react-markdown": "^8.0.5", 45 | "react-syntax-highlighter": "^15.5.0", 46 | "remark-gfm": "^3.0.1", 47 | "superjson": "1.9.1", 48 | "use-debounce": "^9.0.3", 49 | "zod": "^3.20.2", 50 | "zustand": "^4.3.3" 51 | }, 52 | "devDependencies": { 53 | "@tailwindcss/typography": "^0.5.9", 54 | "@types/diff-match-patch": "^1.0.36", 55 | "@types/node": "^18.11.18", 56 | "@types/prettier": "^2.7.2", 57 | "@types/react": "^18.0.26", 58 | "@types/react-dom": "^18.0.10", 59 | "@types/react-syntax-highlighter": "^15.5.6", 60 | "@typescript-eslint/eslint-plugin": "^5.47.1", 61 | "@typescript-eslint/parser": "^5.47.1", 62 | "autoprefixer": "^10.4.7", 63 | "eslint": "^8.30.0", 64 | "eslint-config-next": "13.1.6", 65 | "postcss": "^8.4.14", 66 | "prettier": "^2.8.1", 67 | "prettier-plugin-tailwindcss": "^0.2.1", 68 | "prisma": "^5.11.0", 69 | "tailwindcss": "^3.2.0", 70 | "tsx": "^3.12.3", 71 | "typescript": "^4.9.4" 72 | }, 73 | "ct3aMetadata": { 74 | "initVersion": "7.4.1" 75 | } 76 | } -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import("prettier").Config} */ 2 | module.exports = { 3 | plugins: [require.resolve("prettier-plugin-tailwindcss")], 4 | }; 5 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "postgresql" 10 | // NOTE: When using postgresql, mysql or sqlserver, uncomment the @db.Text annotations in model Account below 11 | // Further reading: 12 | // https://next-auth.js.org/adapters/prisma#create-the-prisma-schema 13 | // https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#string 14 | url = env("DATABASE_URL") 15 | } 16 | 17 | model Example { 18 | id String @id @default(cuid()) 19 | createdAt DateTime @default(now()) 20 | updatedAt DateTime @updatedAt 21 | } 22 | 23 | // Necessary for Next auth 24 | model Account { 25 | id String @id @default(cuid()) 26 | userId String 27 | type String 28 | provider String 29 | providerAccountId String 30 | refresh_token String? @db.Text 31 | access_token String? @db.Text 32 | expires_at Int? 33 | token_type String? 34 | scope String? 35 | id_token String? @db.Text 36 | session_state String? 37 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 38 | 39 | @@unique([provider, providerAccountId]) 40 | } 41 | 42 | model Session { 43 | id String @id @default(cuid()) 44 | sessionToken String @unique 45 | userId String 46 | expires DateTime 47 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 48 | } 49 | 50 | model User { 51 | id String @id @default(cuid()) 52 | name String? 53 | email String? @unique 54 | emailVerified DateTime? 55 | image String? 56 | accounts Account[] 57 | sessions Session[] 58 | notes Note[] 59 | tags Tag[] 60 | } 61 | 62 | enum Color { 63 | sky 64 | red 65 | green 66 | violet 67 | yellow 68 | lightGray 69 | darkGray 70 | } 71 | 72 | model Tag { 73 | id String @id @default(cuid()) 74 | createdAt DateTime @default(now()) 75 | label String 76 | color Color 77 | notes Note[] 78 | User User @relation(fields: [userId], references: [id], onDelete: Cascade) 79 | userId String 80 | } 81 | 82 | model Note { 83 | id String @id @default(cuid()) 84 | createdAt DateTime @default(now()) 85 | lastUpdated DateTime @default(now()) 86 | title String 87 | content String @default("") @db.Text 88 | tags Tag[] 89 | User User @relation(fields: [userId], references: [id], onDelete: Cascade) 90 | userId String 91 | } 92 | 93 | model VerificationToken { 94 | identifier String 95 | token String @unique 96 | expires DateTime 97 | 98 | @@unique([identifier, token]) 99 | } 100 | -------------------------------------------------------------------------------- /public/images/editor-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChromeUniverse/luccanotes/3992c89554fcf394847d98a08cebf3958c69fc97/public/images/editor-light.png -------------------------------------------------------------------------------- /public/images/editor-mobile-light.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChromeUniverse/luccanotes/3992c89554fcf394847d98a08cebf3958c69fc97/public/images/editor-mobile-light.jpg -------------------------------------------------------------------------------- /public/images/logos/fa.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /public/images/logos/headless-ui.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 9 | 12 | 15 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /public/images/logos/nextjs-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /public/images/logos/postgresql.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChromeUniverse/luccanotes/3992c89554fcf394847d98a08cebf3958c69fc97/public/images/logos/postgresql.png -------------------------------------------------------------------------------- /public/images/logos/prisma.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChromeUniverse/luccanotes/3992c89554fcf394847d98a08cebf3958c69fc97/public/images/logos/prisma.jpg -------------------------------------------------------------------------------- /public/images/logos/prisma.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChromeUniverse/luccanotes/3992c89554fcf394847d98a08cebf3958c69fc97/public/images/logos/prisma.png -------------------------------------------------------------------------------- /public/images/logos/react-query.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | emblem-light 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /public/images/logos/tailwindcss.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/images/notes-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChromeUniverse/luccanotes/3992c89554fcf394847d98a08cebf3958c69fc97/public/images/notes-light.png -------------------------------------------------------------------------------- /public/images/notes-mobile-light.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChromeUniverse/luccanotes/3992c89554fcf394847d98a08cebf3958c69fc97/public/images/notes-mobile-light.jpg -------------------------------------------------------------------------------- /public/images/preview-mobile-light.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChromeUniverse/luccanotes/3992c89554fcf394847d98a08cebf3958c69fc97/public/images/preview-mobile-light.jpg -------------------------------------------------------------------------------- /src/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | faDiscord, 3 | faGithub, 4 | faGoogle, 5 | } from "@fortawesome/free-brands-svg-icons"; 6 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 7 | import { cva, type VariantProps } from "class-variance-authority"; 8 | import Link from "next/link"; 9 | import { 10 | ArrowSquareOut, 11 | CaretDown, 12 | DotsThreeOutlineVertical, 13 | Download, 14 | Eye, 15 | EyeSlash, 16 | FloppyDisk, 17 | NotePencil, 18 | PencilSimple, 19 | Plus, 20 | SignIn, 21 | Spinner, 22 | Tag, 23 | Trash, 24 | } from "phosphor-react"; 25 | import Tooltip, { 26 | type TooltipAlignment, 27 | type TooltipPosition, 28 | } from "./Tooltip"; 29 | 30 | // CVA Variants 31 | const buttonStyles = cva( 32 | "peer flex items-center justify-center disabled:cursor-not-allowed font-semibold border-2", 33 | { 34 | variants: { 35 | intent: { 36 | primary: 37 | "bg-blue-600 border-transparent text-white hover:brightness-[85%] focus-visible:brightness-[85%]", 38 | secondary: 39 | "bg-white dark:bg-gray-950 border-transparent text-gray-400 dark:text-gray-500 hover:brightness-95 dark:hover:brightness-100 hover:text-blue-600 dark:hover:text-blue-600 focus-visible:brightness-95 dark:focus-visible:brightness-100 focus-visible:text-blue-600 dark:focus-visible:text-blue-600 dark:hover:border-blue-600 dark:focus-visible:border-blue-600 dark:outline-none dark:border-2 dark:border-transparent dark:hover:bg-opacity-50", 40 | outline: 41 | "bg-white border-2 border-blue-600 text-blue-600 hover:brightness-[95%] focus-visible:brightness-[95%]", 42 | secondaryAlt: 43 | "dark:bg-gray-850 bg-gray-100 text-gray-600 dark:text-gray-100 hover:bg-gray-200 dark:hover:bg-gray-950 hover:brightness-95 dark:hover:brightness-100 hover:text-blue-600 focus-visible:brightness-95 dark:focus-visible:brightness-100 focus-visible:text-blue-600 border-transparent", 44 | secondaryAltTransparent: 45 | "bg-transparent text-gray-600 dark:text-gray-100 hover:bg-gray-200 dark:hover:bg-gray-900 dark:focus-visible:bg-gray-900 hover:brightness-95 dark:hover:brightness-100 hover:text-blue-600 dark:hover:text-blue-600 focus-visible:brightness-95 dark:focus-visible:brightness-100 focus-visible:text-blue-600 dark:focus-visible:text-blue-600 border-transparent", 46 | dangerPrimary: 47 | "bg-red-500 text-white hover:brightness-[85%] focus-visible:brightness-[85%] border-transparent", 48 | dangerSecondary: 49 | "bg-white border-red-500 dark:bg-gray-850 text-red-500 hover:text-red-500 hover:brightness-95 focus-visible:brightness-95 dark:hover:bg-gray-900 dark:focus-visible:bg-gray-900", 50 | }, 51 | roundedFull: { 52 | true: "rounded-[28px] hover:rounded-xl transition-[border-radius]", 53 | false: "rounded-lg", 54 | }, 55 | shadow: { 56 | true: "drop-shadow-lg", 57 | }, 58 | size: { 59 | lg: "w-14 h-14", 60 | regular: "w-10 h-10", 61 | rectangle: "gap-2 py-2 px-4", 62 | }, 63 | reverse: { 64 | true: "flex-row-reverse", 65 | false: "flex-row", 66 | }, 67 | disabled: { 68 | true: "brightness-75 hover:brightness-75 focus-visible:brightness-75", 69 | }, 70 | }, 71 | defaultVariants: { 72 | roundedFull: false, 73 | shadow: false, 74 | reverse: false, 75 | disabled: false, 76 | }, 77 | } 78 | ); 79 | 80 | type ButtonIconNames = 81 | | "note-pencil" 82 | | "note-pencil-sm" 83 | | "tag" 84 | | "arrow-square-out" 85 | | "three-dots" 86 | | "caret-down" 87 | | "plus" 88 | | "pencil-simple" 89 | | "trash" 90 | | "eye" 91 | | "eye-slash" 92 | | "download" 93 | | "sign-in" 94 | | "floppy" 95 | | "github" 96 | | "discord" 97 | | "google"; 98 | 99 | // Icons and styling props 100 | // const IconProps: IconProps = { size: 28, weight: "bold" }; 101 | const icons: Record = { 102 | "note-pencil": , 103 | "note-pencil-sm": , 104 | tag: , 105 | "arrow-square-out": , 106 | "three-dots": , 107 | "caret-down": , 108 | plus: , 109 | "pencil-simple": , 110 | trash: , 111 | eye: , 112 | "eye-slash": , 113 | download: , 114 | "sign-in": , 115 | floppy: , 116 | github: , 117 | discord: , 118 | google: , 119 | } as const; 120 | 121 | // Base Button Props 122 | type ButtonProps = { 123 | icon?: ButtonIconNames; 124 | label: string; 125 | iconOnly?: boolean; 126 | tooltipPosition: TooltipPosition; 127 | tooltipAlignment: TooltipAlignment; 128 | onClick?: (onClickProps: unknown) => void; 129 | href?: string; 130 | loading?: boolean; 131 | }; 132 | 133 | // Merged props 134 | type buttonVariantsProps = VariantProps; 135 | interface Props 136 | extends ButtonProps, 137 | Omit, 138 | Required> {} 139 | 140 | function Button({ 141 | // markup props 142 | icon, 143 | label, 144 | tooltipPosition, 145 | tooltipAlignment, 146 | onClick, 147 | iconOnly = false, 148 | href, 149 | loading, 150 | // styling props 151 | intent, 152 | roundedFull, 153 | shadow, 154 | size, 155 | reverse, 156 | disabled, 157 | }: Props) { 158 | return ( 159 |
160 | {href ? ( 161 | 172 | {loading ? ( 173 | 174 | ) : ( 175 | icon && icons[icon] 176 | )} 177 | {!iconOnly && {label}} 178 | 179 | ) : ( 180 | 199 | )} 200 | {iconOnly && !disabled && ( 201 | 202 | {label} 203 | 204 | )} 205 |
206 | ); 207 | } 208 | 209 | export default Button; 210 | -------------------------------------------------------------------------------- /src/components/CaretUpDownIcon.tsx: -------------------------------------------------------------------------------- 1 | import { CaretDown, CaretUp } from "phosphor-react"; 2 | 3 | function CaretUpDownIcon() { 4 | return ( 5 |
6 | 7 | 8 |
9 | ); 10 | } 11 | export default CaretUpDownIcon; 12 | -------------------------------------------------------------------------------- /src/components/Layouts/Modal.tsx: -------------------------------------------------------------------------------- 1 | import { Dialog } from "@headlessui/react"; 2 | import { type ReactNode } from "react"; 3 | 4 | function ModalLayout({ 5 | open, 6 | onClose, 7 | children, 8 | }: { 9 | open: boolean; 10 | onClose: (newOpen: boolean) => void; 11 | children?: ReactNode; 12 | }) { 13 | return ( 14 | 15 |
16 | 17 | {children} 18 | 19 |
20 |
21 | ); 22 | } 23 | 24 | export default ModalLayout; 25 | -------------------------------------------------------------------------------- /src/components/Layouts/Page.tsx: -------------------------------------------------------------------------------- 1 | import { type Session } from "next-auth"; 2 | import React from "react"; 3 | import Navbar from "../Navbar"; 4 | 5 | function PageLayout({ 6 | container = false, 7 | noteTitle, 8 | children, 9 | session, 10 | }: { 11 | container?: boolean; 12 | noteTitle?: string; 13 | children: React.ReactNode; 14 | session?: Session; 15 | }) { 16 | return ( 17 |
18 | 19 | {container ? ( 20 |
21 |
22 | {children} 23 |
24 |
25 | ) : ( 26 |
27 | {children} 28 |
29 | )} 30 |
31 | ); 32 | } 33 | 34 | export default PageLayout; 35 | -------------------------------------------------------------------------------- /src/components/LoadingNoteCard.tsx: -------------------------------------------------------------------------------- 1 | function LoadingNoteCard() { 2 | return ( 3 |
4 | {/* Fake Text content */} 5 |
6 | {/* Fake Title */} 7 |
8 | {/* Fake timestamp */} 9 |
10 | {/* Fake tags */} 11 |
12 | {/* Fake tag */} 13 |
14 |
15 |
16 |
17 |
18 |
19 | {/* Fake Buttons */} 20 |
21 |
22 |
23 |
24 |
25 | ); 26 | } 27 | 28 | export default LoadingNoteCard; 29 | -------------------------------------------------------------------------------- /src/components/Modals/CreateNote.tsx: -------------------------------------------------------------------------------- 1 | import { Dialog, Listbox } from "@headlessui/react"; 2 | import { Tag } from "@prisma/client"; 3 | import { useRouter } from "next/router"; 4 | import { Check } from "phosphor-react"; 5 | import { useState } from "react"; 6 | import { api } from "../../utils/api"; 7 | import Button from "../Button"; 8 | import CaretUpDownIcon from "../CaretUpDownIcon"; 9 | import ModalLayout from "../Layouts/Modal"; 10 | import TagPill from "../TagPill"; 11 | 12 | function CreateNoteModal({ 13 | open, 14 | onClose, 15 | tags, 16 | }: { 17 | open: boolean; 18 | onClose: (newOpen: boolean) => void; 19 | tags: Tag[]; 20 | }) { 21 | // modal state 22 | const [noteTitle, setNoteTitle] = useState(""); 23 | const [noteTags, setNoteTags] = useState([]); 24 | const [selectedTag, setSelectedTag] = useState(null); 25 | const tagsAvailable = tags.filter((t) => !noteTags.includes(t)); 26 | 27 | // next router 28 | const router = useRouter(); 29 | 30 | // trpc 31 | const utils = api.useContext(); 32 | const createNoteMutation = api.notes.create.useMutation(); 33 | 34 | function addTag() { 35 | if (!selectedTag) return; 36 | setSelectedTag(null); 37 | setNoteTags((prevNoteTags) => [...prevNoteTags, selectedTag]); 38 | } 39 | 40 | function onClickCreateNote() { 41 | createNoteMutation.mutate( 42 | { tagIds: noteTags.map((t) => t.id), title: noteTitle }, 43 | { 44 | onSuccess: (createdNote, variables, context) => { 45 | utils.notes.getAll.setData(undefined, (oldNotes) => 46 | oldNotes ? [...oldNotes, createdNote] : [createdNote] 47 | ); 48 | void utils.notes.getAll.invalidate(); 49 | // onClose(false); 50 | void router.push(`/notes/${createdNote.id}`); 51 | }, 52 | } 53 | ); 54 | } 55 | 56 | return ( 57 | 58 | {/* Title & Description */} 59 | 60 | New note 61 | 62 | 63 | This dialog allows you to create a new note 64 | 65 | 66 | {/* Title */} 67 |

68 | Title 69 |

70 | setNoteTitle(e.target.value)} 76 | /> 77 | 78 | {/* Add tags */} 79 | {tagsAvailable.length !== 0 && ( 80 | <> 81 |

82 | Add tags 83 |

84 | {/* New tag color selector */} 85 |
86 | 92 | {/* Button */} 93 | 94 | {selectedTag ? ( 95 | selectedTag.label 96 | ) : ( 97 | 98 | Select a tag to add 99 | 100 | )} 101 | 102 | 103 | 104 | {/* Options */} 105 | 106 | {tagsAvailable.map((tag, index) => ( 107 | 112 | 117 | {tag.label} 118 | 119 | ))} 120 | 121 | 122 | 123 |
135 | 136 | )} 137 | 138 | {/* Tags */} 139 |

140 | Tags 141 |

142 |
143 | {noteTags.length !== 0 ? ( 144 | noteTags.map((tag: Tag) => ( 145 | 151 | setNoteTags((prevTags) => 152 | prevTags.filter((t) => t.id !== tag.id) 153 | ) 154 | } 155 | /> 156 | )) 157 | ) : ( 158 | No tags - nothing to see here. 159 | )} 160 |
161 | 162 | 50 | ) : ( 51 |
{children}
52 | ); 53 | } 54 | 55 | function Logo({ session }: { session?: Session }) { 56 | return ( 57 | 58 | {/* Icon */} 59 |
60 | 61 |
62 | {/* App Name */} 63 | {/* {session && ( */} 64 | 65 | LuccaNotes 66 | 67 | {/* )} */} 68 | 69 | ); 70 | } 71 | 72 | function ThemeSelector() { 73 | const { theme, setTheme } = useThemeStore(); 74 | return ( 75 | 81 | {/* Button */} 82 | 83 | {theme === "light" ? "Light" : "Dark"} 84 | 85 | 86 | 87 | {/* Options */} 88 | 89 | {/* Light option */} 90 | 94 | 95 | 96 | {/* Dark option */} 97 | 101 | 102 | 103 | 104 | 105 | ); 106 | } 107 | 108 | function PfpDropdown({ session }: { session: Session }) { 109 | return ( 110 | 111 | {/* PFP button */} 112 | 113 | profile picture 119 | 120 | 121 | {/* Dropdown menu */} 122 | 130 | 131 | {/* Account settings */} 132 | {/* 133 | 138 | Account settings 139 | */} 140 | {/* Theme Selector */} 141 | 142 | 147 | Theme 148 | 149 | 150 | 151 | {/* About/more info */} 152 | {/* 153 | 158 | About 159 | */} 160 | {/* Logout */} 161 |
162 | void signOut({ callbackUrl: "/" })}> 163 | 168 | Log out 169 | 170 |
171 |
172 |
173 | ); 174 | } 175 | 176 | function Navbar({ 177 | bgTransparent, 178 | noteTitle, 179 | session, 180 | }: { 181 | bgTransparent?: boolean; 182 | noteTitle?: string; 183 | session?: Session; 184 | }) { 185 | return ( 186 | 272 | ); 273 | } 274 | 275 | export default Navbar; 276 | -------------------------------------------------------------------------------- /src/components/NoteCard.tsx: -------------------------------------------------------------------------------- 1 | import { type Note, type Tag } from "@prisma/client"; 2 | import { useRouter } from "next/router"; 3 | import { useEffect, useState } from "react"; 4 | import { NoteWithTags } from ".."; 5 | import { formatDate } from "../utils/dates"; 6 | import Button from "./Button"; 7 | import NoteOptionsModal from "./Modals/NoteOptions"; 8 | import TagPill from "./TagPill"; 9 | import Tooltip from "./Tooltip"; 10 | 11 | function HiddenTagPillContainer({ 12 | hiddenTags, 13 | flipTags, 14 | }: { 15 | hiddenTags: Tag[]; 16 | flipTags: boolean; 17 | }) { 18 | return ( 19 | <> 20 | {/* Mobile */} 21 |
22 |

23 | {hiddenTags.length} more {hiddenTags.length === 1 ? "tag" : "tags"} 24 |

25 | 29 |
30 | {hiddenTags.map((tag, index) => ( 31 | 32 | ))} 33 |
34 |
35 |
36 | {/* Desktop */} 37 |
38 |

39 | {hiddenTags.length} more {hiddenTags.length === 1 ? "tag" : "tags"} 40 |

41 | 42 |
43 | {hiddenTags.map((tag, index) => ( 44 | 45 | ))} 46 |
47 |
48 |
49 | 50 | ); 51 | } 52 | 53 | function TagPillContainer({ 54 | tags, 55 | flipTags = false, 56 | }: { 57 | tags: Tag[]; 58 | flipTags: boolean; 59 | }) { 60 | return ( 61 | <> 62 | {/* Mobile Tag container */} 63 |
64 | {tags[0] && } 65 | {tags.length - 1 > 0 && ( 66 | 70 | )} 71 | {tags.length === 0 && ( 72 | No tags for this note yet! 73 | )} 74 |
75 | {/* Desktop Tag container */} 76 |
77 | {tags[0] && } 78 | {tags[1] && } 79 | {tags.length - 2 > 0 && ( 80 | 84 | )} 85 | {tags.length === 0 && ( 86 | No tags for this note yet! 87 | )} 88 |
89 | 90 | ); 91 | } 92 | 93 | function NoteCard({ 94 | note, 95 | flipTags = false, 96 | dateType = "lastUpdated", 97 | tags, 98 | }: { 99 | note: NoteWithTags; 100 | flipTags?: boolean; 101 | dateType?: "lastUpdated" | "createdAt"; 102 | tags: Tag[]; 103 | }) { 104 | const [modalOpen, setModalOpen] = useState(false); 105 | const [navLoading, setNavLoading] = useState(false); 106 | 107 | const router = useRouter(); 108 | 109 | useEffect(() => { 110 | const handler = (url: string) => { 111 | if (url.includes(note.id)) { 112 | setNavLoading(true); 113 | } 114 | }; 115 | 116 | router.events.on("routeChangeStart", handler); 117 | 118 | return () => { 119 | router.events.off("routeChangeStart", handler); 120 | }; 121 | }, []); 122 | 123 | return ( 124 |
125 |
126 | {/* Note title */} 127 |

128 | {note.title} 129 |

130 | 131 | {/* Date field */} 132 | {dateType === "lastUpdated" ? ( 133 |

134 | Last edited {formatDate(note.lastUpdated)} 135 |

136 | ) : ( 137 |

138 | Created {formatDate(note.createdAt)} 139 |

140 | )} 141 | 142 | 143 |
144 | 145 | {/* Buttons */} 146 |
147 |
169 | 170 | n.id === selectedNoteId) ?? null} 175 | note={note} 176 | tags={tags} 177 | /> 178 |
179 | ); 180 | } 181 | 182 | export default NoteCard; 183 | -------------------------------------------------------------------------------- /src/components/SearchBar.tsx: -------------------------------------------------------------------------------- 1 | import { Listbox, Popover, RadioGroup, Transition } from "@headlessui/react"; 2 | import { type Tag } from "@prisma/client"; 3 | import { 4 | ArrowCircleDown, 5 | ArrowCircleUp, 6 | CaretDown, 7 | Check, 8 | MagnifyingGlass, 9 | } from "phosphor-react"; 10 | import { type ChangeEvent, useState } from "react"; 11 | import { useDebouncedCallback } from "use-debounce"; 12 | import useSearchStore from "../stores/search"; 13 | import CaretUpDownIcon from "./CaretUpDownIcon"; 14 | import TagPill from "./TagPill"; 15 | import Tooltip from "./Tooltip"; 16 | 17 | // Sort fields 18 | const sortFieldLabels = { 19 | title: "Title", 20 | createdAt: "Creation Date", 21 | lastUpdated: "Last Updated", 22 | } as const; 23 | export type SortField = keyof typeof sortFieldLabels; 24 | 25 | function SortingSection() { 26 | const { sortField, setSortField, sortOrder, setSortOrder } = useSearchStore(); 27 | 28 | return ( 29 |
30 |

31 | Sorting 32 |

33 |
34 | {/* Sort Field Selector */} 35 |
36 | {/* Label */} 37 | Sort by 38 | 44 | {/* Button */} 45 | 46 | {sortFieldLabels[sortField]} 47 | 48 | 49 | 50 | {/* Dropdown menu */} 51 | 52 | {Object.keys(sortFieldLabels).map((sortFieldOption, index) => ( 53 | 58 | 63 | {sortFieldLabels[sortFieldOption as SortField]} 64 | 65 | ))} 66 | 67 | 68 |
69 | 70 | {/* Sort Order Selector */} 71 | 77 | {/* Label */} 78 | 79 | Order 80 | 81 | {/* Options */} 82 | {sortField === "title" ? ( 83 | // sorting by title 84 |
85 | 86 | 87 | A-z 88 | 89 | 90 | 91 | 92 | Z-a 93 | 94 | 95 |
96 | ) : ( 97 | // sorting by 'createdAt' or 'lastUpdate' 98 |
99 | 100 | 105 | 106 | 107 | 112 | 113 |
114 | )} 115 |
116 |
117 |
118 | ); 119 | } 120 | 121 | function TagsSection({ tags }: { tags: Tag[] }) { 122 | const { selectedTagIds, toggleSelectedTag } = useSearchStore(); 123 | 124 | console.log("selectedTagIds is", selectedTagIds); 125 | 126 | return ( 127 |
128 |

129 | Filter by Tags{" "} 130 | 131 | Click tags to toggle filter 132 | 133 |

134 |
135 | {tags.map(({ id, label, color }) => ( 136 |
toggleSelectedTag(id)} 140 | > 141 | 150 |
151 | ))} 152 |
153 |
154 | ); 155 | } 156 | 157 | function SearchBar({ tags }: { tags: Tag[] }) { 158 | const { setSearchInput } = useSearchStore(); 159 | 160 | const [input, setInput] = useState(""); 161 | 162 | const debouncedSetInput = useDebouncedCallback((textInput: string) => { 163 | setSearchInput(textInput); 164 | }, 200); 165 | 166 | const onChange = (e: ChangeEvent) => { 167 | setInput(e.target.value); 168 | debouncedSetInput(e.target.value); 169 | }; 170 | 171 | return ( 172 |
173 | 174 | 181 | 182 | 183 | 184 | {({ open }) => ( 185 | <> 186 | {/* Desktop */} 187 |
188 |
189 | 194 |
195 | {!open && ( 196 | 197 | Sorting & Filtering 198 | 199 | )} 200 |
201 | {/* Mobile */} 202 |
203 |
204 | 209 |
210 | {!open && ( 211 | 212 | Sorting & Filtering 213 | 214 | )} 215 |
216 | 217 | )} 218 |
219 | 227 | 228 | 229 | 230 | 231 | 232 |
233 |
234 | ); 235 | } 236 | 237 | export default SearchBar; 238 | -------------------------------------------------------------------------------- /src/components/TagPill.tsx: -------------------------------------------------------------------------------- 1 | import { Color } from "@prisma/client"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | import { Spinner, X } from "phosphor-react"; 4 | import Tooltip from "./Tooltip"; 5 | 6 | const tagPillStyles = cva("py-1 px-4 rounded-full select-none", { 7 | variants: { 8 | color: { 9 | sky: "bg-sky-200 text-sky-700", 10 | red: "bg-red-200 text-red-700", 11 | green: "bg-green-200 text-green-700", 12 | violet: "bg-violet-200 text-violet-700", 13 | yellow: "bg-yellow-200 text-yellow-700", 14 | lightGray: "bg-gray-300 text-gray-700", 15 | darkGray: "bg-gray-700 text-gray-300", 16 | }, 17 | deletable: { 18 | true: "rounded-r-none", 19 | }, 20 | dark: { 21 | true: "brightness-75 opacity-75 dark:brightness-50", 22 | }, 23 | }, 24 | defaultVariants: { 25 | deletable: false, 26 | dark: false, 27 | }, 28 | }); 29 | 30 | type TagPillProps = { 31 | label: string; 32 | onClickDelete?: (onClickDeleteProps: any) => void; 33 | loading?: boolean; 34 | destructive?: boolean; 35 | }; 36 | 37 | type TagPillVariantProps = VariantProps; 38 | export type TagColor = NonNullable; 39 | interface Props extends TagPillProps, TagPillVariantProps {} 40 | 41 | // type TagPillVariantProps = VariantProps; 42 | // export type TagColor = NonNullable; 43 | // type ProcessedVariantProps = 44 | // | Omit 45 | // | (Required & { 46 | // onClickDelete: (onClickDeleteProps: any) => void; 47 | // }); 48 | // // interface Props extends TagPillProps, Omit {} 49 | // interface Props extends TagPillProps, ProcessedVariantProps {} 50 | 51 | export const tagColorNames: Record = { 52 | sky: "Sky", 53 | red: "Red", 54 | green: "Green", 55 | violet: "Violet", 56 | yellow: "Yellow", 57 | lightGray: "Light Gray", 58 | darkGray: "Dark Gray", 59 | } as const; 60 | 61 | function TagPill({ 62 | label, 63 | color, 64 | deletable, 65 | onClickDelete, 66 | loading = false, 67 | destructive = false, 68 | dark, 69 | }: Props) { 70 | return ( 71 |
72 |
{label}
73 | {deletable && ( 74 |
75 | 94 | {!loading && ( 95 | 96 | {destructive ? "Delete tag" : "Remove tag"} 97 | 98 | )} 99 |
100 | )} 101 |
102 | ); 103 | } 104 | 105 | export default TagPill; 106 | -------------------------------------------------------------------------------- /src/components/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { cva, type VariantProps } from "class-variance-authority"; 2 | 3 | const tooltipStyles = cva( 4 | "absolute z-10 min-w-max rounded-lg bg-gray-100 dark:bg-gray-800 py-2 px-4 font-semibold text-gray-800 dark:text-gray-200 drop-shadow-lg transition-all scale-0 peer-hover:scale-100 peer-focus-visible:scale-100 group-focus-visible:scale-100 outline-none", 5 | { 6 | variants: { 7 | tooltipPosition: { 8 | top: "left-1/2 -translate-x-1/2 origin-bottom bottom-full mb-3", 9 | bottom: "origin-top top-full mt-3", 10 | left: "top-1/2 -translate-y-1/2 origin-right right-full mr-3", 11 | right: "origin-left left-full ml-3", 12 | }, 13 | alignment: { 14 | left: "right-0", 15 | xCenter: "left-1/2 -translate-x-1/2", 16 | right: "left-0", 17 | top: "top-0", 18 | yCenter: "top-1/2 -translate-y-1/2", 19 | bottom: "bottom-0", 20 | }, 21 | }, 22 | } 23 | ); 24 | 25 | type ToolTipProps = { 26 | // label: string; 27 | children: React.ReactNode; 28 | }; 29 | 30 | type TooltipVariantProps = Required>; 31 | export type TooltipPosition = TooltipVariantProps["tooltipPosition"]; 32 | export type TooltipAlignment = TooltipVariantProps["alignment"]; 33 | 34 | interface Props 35 | extends ToolTipProps, 36 | Required> {} 37 | 38 | const Tooltip = ({ tooltipPosition, children, alignment }: Props) => { 39 | return ( 40 |
41 | {children} 42 |
43 | ); 44 | }; 45 | 46 | export default Tooltip; 47 | -------------------------------------------------------------------------------- /src/env/client.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { clientEnv, clientSchema } from "./schema.mjs"; 3 | 4 | const _clientEnv = clientSchema.safeParse(clientEnv); 5 | 6 | export const formatErrors = ( 7 | /** @type {import('zod').ZodFormattedError,string>} */ 8 | errors, 9 | ) => 10 | Object.entries(errors) 11 | .map(([name, value]) => { 12 | if (value && "_errors" in value) 13 | return `${name}: ${value._errors.join(", ")}\n`; 14 | }) 15 | .filter(Boolean); 16 | 17 | if (!_clientEnv.success) { 18 | console.error( 19 | "❌ Invalid environment variables:\n", 20 | ...formatErrors(_clientEnv.error.format()), 21 | ); 22 | throw new Error("Invalid environment variables"); 23 | } 24 | 25 | for (let key of Object.keys(_clientEnv.data)) { 26 | if (!key.startsWith("NEXT_PUBLIC_")) { 27 | console.warn( 28 | `❌ Invalid public environment variable name: ${key}. It must begin with 'NEXT_PUBLIC_'`, 29 | ); 30 | 31 | throw new Error("Invalid public environment variable name"); 32 | } 33 | } 34 | 35 | export const env = _clientEnv.data; 36 | -------------------------------------------------------------------------------- /src/env/schema.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { z } from "zod"; 3 | 4 | /** 5 | * Specify your server-side environment variables schema here. 6 | * This way you can ensure the app isn't built with invalid env vars. 7 | */ 8 | export const serverSchema = z.object({ 9 | DATABASE_URL: z.string().url(), 10 | NODE_ENV: z.enum(["development", "test", "production"]), 11 | NEXTAUTH_SECRET: 12 | process.env.NODE_ENV === "production" 13 | ? z.string().min(1) 14 | : z.string().min(1).optional(), 15 | NEXTAUTH_URL: z.preprocess( 16 | // This makes Vercel deployments not fail if you don't set NEXTAUTH_URL 17 | // Since NextAuth.js automatically uses the VERCEL_URL if present. 18 | (str) => process.env.VERCEL_URL ?? str, 19 | // VERCEL_URL doesn't include `https` so it cant be validated as a URL 20 | process.env.VERCEL ? z.string() : z.string().url() 21 | ), 22 | DISCORD_CLIENT_ID: z.string(), 23 | DISCORD_CLIENT_SECRET: z.string(), 24 | GOOGLE_CLIENT_ID: z.string(), 25 | GOOGLE_CLIENT_SECRET: z.string(), 26 | }); 27 | 28 | /** 29 | * You can't destruct `process.env` as a regular object in the Next.js 30 | * middleware, so you have to do it manually here. 31 | * @type {{ [k in keyof z.input]: string | undefined }} 32 | */ 33 | export const serverEnv = { 34 | DATABASE_URL: process.env.DATABASE_URL, 35 | NODE_ENV: process.env.NODE_ENV, 36 | NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, 37 | NEXTAUTH_URL: process.env.NEXTAUTH_URL, 38 | DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID, 39 | DISCORD_CLIENT_SECRET: process.env.DISCORD_CLIENT_SECRET, 40 | GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, 41 | GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET, 42 | }; 43 | 44 | /** 45 | * Specify your client-side environment variables schema here. 46 | * This way you can ensure the app isn't built with invalid env vars. 47 | * To expose them to the client, prefix them with `NEXT_PUBLIC_`. 48 | */ 49 | export const clientSchema = z.object({ 50 | // NEXT_PUBLIC_CLIENTVAR: z.string(), 51 | }); 52 | 53 | /** 54 | * You can't destruct `process.env` as a regular object, so you have to do 55 | * it manually here. This is because Next.js evaluates this at build time, 56 | * and only used environment variables are included in the build. 57 | * @type {{ [k in keyof z.input]: string | undefined }} 58 | */ 59 | export const clientEnv = { 60 | // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR, 61 | }; 62 | -------------------------------------------------------------------------------- /src/env/server.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | /** 3 | * This file is included in `/next.config.mjs` which ensures the app isn't built with invalid env vars. 4 | * It has to be a `.mjs`-file to be imported there. 5 | */ 6 | import { serverSchema, serverEnv } from "./schema.mjs"; 7 | import { env as clientEnv, formatErrors } from "./client.mjs"; 8 | 9 | const _serverEnv = serverSchema.safeParse(serverEnv); 10 | 11 | if (!_serverEnv.success) { 12 | console.error( 13 | "❌ Invalid environment variables:\n", 14 | ...formatErrors(_serverEnv.error.format()), 15 | ); 16 | throw new Error("Invalid environment variables"); 17 | } 18 | 19 | for (let key of Object.keys(_serverEnv.data)) { 20 | if (key.startsWith("NEXT_PUBLIC_")) { 21 | console.warn("❌ You are exposing a server-side env-variable:", key); 22 | 23 | throw new Error("You are exposing a server-side env-variable"); 24 | } 25 | } 26 | 27 | export const env = { ..._serverEnv.data, ...clientEnv }; 28 | -------------------------------------------------------------------------------- /src/hooks/useHasHydrated.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | const useHasHydrated = () => { 4 | const [hasHydrated, setHasHydrated] = useState(false); 5 | 6 | useEffect(() => { 7 | setHasHydrated(true); 8 | }, []); 9 | 10 | return hasHydrated; 11 | }; 12 | 13 | export default useHasHydrated; 14 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | import { type Note, type Tag } from "@prisma/client"; 2 | 3 | export type NoteWithTags = Omit & { tags: Tag[] }; 4 | -------------------------------------------------------------------------------- /src/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | 3 | export default function Custom404() { 4 | return ( 5 |
6 | 7 | LuccaNotes • Not Found 8 | 9 |

404 - Page Not Found

10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { type AppType } from "next/app"; 2 | import { type Session } from "next-auth"; 3 | import { SessionProvider } from "next-auth/react"; 4 | 5 | import { api } from "../utils/api"; 6 | 7 | import "../styles/globals.css"; 8 | 9 | import { Inter } from "@next/font/google"; 10 | import useThemeStore from "../stores/theme"; 11 | // import useHasHydrated from "../hooks/useHasHydrated"; 12 | import { useEffect } from "react"; 13 | import { useRouter } from "next/router"; 14 | 15 | const inter = Inter({ 16 | subsets: ["latin"], 17 | }); 18 | 19 | const MyApp: AppType<{ session: Session | null }> = ({ 20 | Component, 21 | pageProps: { session, ...pageProps }, 22 | // pageProps, 23 | }) => { 24 | const { theme } = useThemeStore(); 25 | 26 | const { pathname } = useRouter(); 27 | 28 | useEffect(() => { 29 | if (pathname === "/" || pathname.includes("auth")) return; 30 | 31 | theme === "dark" 32 | ? document.body.classList.add("dark") 33 | : document.body.classList.remove("dark"); 34 | }, [theme, pathname]); 35 | 36 | return ( 37 | <> 38 | {/* Inter font wrapper */} 39 | 44 | 45 | 46 | 47 | 48 | ); 49 | }; 50 | 51 | export default api.withTRPC(MyApp); 52 | -------------------------------------------------------------------------------- /src/pages/api/auth/[...nextauth].ts: -------------------------------------------------------------------------------- 1 | import NextAuth from "next-auth"; 2 | import { authOptions } from "../../../server/auth"; 3 | 4 | export default NextAuth(authOptions); 5 | -------------------------------------------------------------------------------- /src/pages/api/trpc/[trpc].ts: -------------------------------------------------------------------------------- 1 | import { createNextApiHandler } from "@trpc/server/adapters/next"; 2 | 3 | import { env } from "../../../env/server.mjs"; 4 | import { createTRPCContext } from "../../../server/api/trpc"; 5 | import { appRouter } from "../../../server/api/root"; 6 | 7 | // export API handler 8 | export default createNextApiHandler({ 9 | router: appRouter, 10 | createContext: createTRPCContext, 11 | onError: 12 | env.NODE_ENV === "development" 13 | ? ({ path, error }) => { 14 | console.error( 15 | `❌ tRPC failed on ${path ?? ""}: ${error.message}`, 16 | ); 17 | } 18 | : undefined, 19 | }); 20 | -------------------------------------------------------------------------------- /src/pages/auth/signin.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | faDiscord, 3 | faGithub, 4 | faGoogle, 5 | } from "@fortawesome/free-brands-svg-icons"; 6 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 7 | import { 8 | GetServerSideProps, 9 | GetServerSidePropsContext, 10 | InferGetServerSidePropsType, 11 | } from "next"; 12 | import { getProviders, signIn } from "next-auth/react"; 13 | import Link from "next/link"; 14 | import { BookBookmark } from "phosphor-react"; 15 | import React from "react"; 16 | import Button from "../../components/Button"; 17 | import PageLayout from "../../components/Layouts/Page"; 18 | import { getServerAuthSession } from "../../server/auth"; 19 | 20 | function Logo() { 21 | return ( 22 | 23 | {/* Icon */} 24 |
25 | 26 |
27 | {/* App Name */} 28 | {/* 29 | LuccaNotes 30 | */} 31 | 32 | ); 33 | } 34 | 35 | const iconProps = { 36 | className: "text-blue-600 text-xl group-hover:text-white", 37 | }; 38 | 39 | function SignInPage({ 40 | providers, 41 | }: InferGetServerSidePropsType) { 42 | return ( 43 |
49 | {/* Logo */} 50 | 51 | 52 | {/* Login options */} 53 |
54 |

Sign in with

55 | 56 | {/* Login buttons container */} 57 |
58 | {Object.values(providers).map((provider) => ( 59 | // Login button 60 | 80 | ))} 81 |
82 |
83 |
84 | ); 85 | } 86 | 87 | export const getServerSideProps = async ( 88 | context: GetServerSidePropsContext 89 | ) => { 90 | const session = await getServerAuthSession(context); 91 | 92 | if (session) { 93 | return { 94 | redirect: { destination: "/notes" }, 95 | }; 96 | } 97 | 98 | const providers = await getProviders(); 99 | 100 | return { props: { providers: providers ?? [] } }; 101 | }; 102 | 103 | export default SignInPage; 104 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { type GetServerSidePropsContext, type NextPage } from "next"; 2 | import { type Session } from "next-auth"; 3 | import { signIn } from "next-auth/react"; 4 | import { getServerAuthSession } from "../server/auth"; 5 | 6 | // Phosphor and FontAwesome icons 7 | import { 8 | BookBookmark, 9 | Eye, 10 | FloppyDisk, 11 | Keyboard, 12 | MagnifyingGlass, 13 | Tag, 14 | } from "phosphor-react"; 15 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 16 | import { faMarkdown } from "@fortawesome/free-brands-svg-icons"; 17 | import Button from "../components/Button"; 18 | import Tooltip from "../components/Tooltip"; 19 | import Navbar from "../components/Navbar"; 20 | import Head from "next/head"; 21 | 22 | export const getServerSideProps = async ( 23 | context: GetServerSidePropsContext 24 | ) => { 25 | const session = await getServerAuthSession(context); 26 | 27 | if (session) { 28 | return { 29 | redirect: { 30 | destination: "/notes", 31 | permanent: false, 32 | }, 33 | }; 34 | } 35 | 36 | // Pass data to the page via props 37 | return { props: { session: null } }; 38 | }; 39 | 40 | const iconProps = { 41 | className: 42 | "rounded-tl-xl md:rounded-full bg-blue-600 h-16 md:h-14 w-16 md:w-14 p-3.5 md:p-3 text-white md:drop-shadow-md", 43 | weight: "bold", 44 | } as const; 45 | 46 | type Feature = { 47 | icon: JSX.Element; 48 | title: string; 49 | content: string; 50 | }; 51 | 52 | const features: Feature[] = [ 53 | { 54 | icon: ( 55 |
56 | 57 |
58 | ), 59 | title: "GitHub Flavored Markdown", 60 | content: 61 | "The best flavor of Markdown! The GFM spec is supported by LuccaNotes' text editor and Markdown renderer for note previews.", 62 | }, 63 | { 64 | icon: , 65 | title: "Auto-saving", 66 | content: 67 | "Changes to your notes are automatically saved to LuccaNotes' backend, meaning you'll never need to worry about losing your work.", 68 | }, 69 | { 70 | icon: , 71 | title: "Tags keep notes tidy", 72 | content: 73 | "Our tagging system allows you to effortlessly group, organize, and search through your notes - regardless if you have 5 or 5000!", 74 | }, 75 | { 76 | icon: , 77 | title: "Note previews", 78 | content: 79 | "The toggleable Markdown preview displays a fully rendered version of your note's content as you type it out in the text editor.", 80 | }, 81 | { 82 | icon: , 83 | title: "Keyboard navigation & a11y", 84 | content: 85 | "LuccaNotes is built with full accessibility in mind. In addition to a more inclusive UX, this allows for speedy keyboard navigation throughout the entire app.", 86 | }, 87 | { 88 | icon: , 89 | title: "Sort, Filter & Search", 90 | content: 91 | "A sensible and easy-to-use search tool lets you quickly browse your collection and find the note you're looking for. It's as simple as that!", 92 | }, 93 | ]; 94 | 95 | function FeatureCard({ feature: f }: { feature: Feature }) { 96 | return ( 97 |
98 | {/* Icon */} 99 |
100 | {f.icon} 101 |
102 | {/* Title */} 103 |

{f.title}

104 | {/* Content */} 105 |

{f.content}

106 |
107 | ); 108 | } 109 | 110 | function Section({ 111 | id, 112 | title, 113 | heading, 114 | description, 115 | children, 116 | }: { 117 | id: string; 118 | title: string; 119 | heading: string; 120 | description: string; 121 | children?: React.ReactNode; 122 | }) { 123 | return ( 124 |
125 |
126 |

{title}

127 |

128 | {heading} 129 |

130 |

{description}

131 | {children} 132 |
133 |
134 | ); 135 | } 136 | 137 | function TechLogo({ 138 | label, 139 | src, 140 | link, 141 | tooltipPosition = "bottom", 142 | rounded = false, 143 | }: { 144 | label: string; 145 | src: string; 146 | link: string; 147 | tooltipPosition?: "bottom" | "top"; 148 | rounded?: boolean; 149 | }) { 150 | return ( 151 | 170 | ); 171 | } 172 | 173 | function Logo() { 174 | return ( 175 |
176 |
177 | 178 |
179 | 180 | LuccaNotes 181 | 182 |
183 | ); 184 | } 185 | 186 | const Home: NextPage = () => { 187 | return ( 188 |
189 | 190 | LuccaNotes • Note-taking app for Markdown lovers 💙 191 | 192 | 193 |
194 | {/* Hero section */} 195 |
202 | {/* Navbar with transparent background */} 203 | 204 | 205 | {/* Title */} 206 |
207 |

208 | The open-source note-taking app for Markdown lovers 💙 209 |

210 | 211 | {/* Subtitle */} 212 |

213 | A no-frills web app for all your Markdown note-taking needs, 214 | laser-focused on productivity and unobtrusive UX, and powered by 215 | awesome open-source tech. 216 |

217 |
218 | 219 | {/* CTA */} 220 |
221 |
243 | 244 | A preview of the main dashboard for LuccaNotes 249 | 250 | {/* Hero image (desktop) */} 251 | A preview of the main dashboard for LuccaNotes 256 |
257 | 258 | {/* Features section */} 259 |
267 | {/* Feature cards */} 268 |
269 | {features.map((f, index) => ( 270 | 271 | ))} 272 |
273 |
274 | 275 | {/* Technologies section */} 276 |
284 | {/* Logos */} 285 |
286 | 292 | 298 | 305 | 311 | 317 | 324 | 330 | 336 | 341 | 346 | 352 | 357 | 362 | 367 | 372 | 377 |
378 |
379 | 380 | {/* CTA section */} 381 |
390 | {/* Sign in button */} 391 |
392 |
404 | 405 | {/* Editor preview image (desktop) */} 406 | A preview of the editor and preview for LuccaNotes (desktop) 411 | 412 | {/* Editor preview image (mobile) */} 413 | 414 |
415 | A preview of the text editor panel in LuccaNotes (mobile) 420 | A preview of the Markdown preview panel in LuccaNotes (mobile) 425 |
426 |
427 | 428 | {/* Footer */} 429 | 444 |
445 |
446 | ); 447 | }; 448 | 449 | export default Home; 450 | -------------------------------------------------------------------------------- /src/pages/notes/[id].tsx: -------------------------------------------------------------------------------- 1 | // next & react 2 | import { 3 | type GetServerSidePropsContext, 4 | type InferGetServerSidePropsType, 5 | } from "next"; 6 | import { Note, Spinner } from "phosphor-react"; 7 | import { memo, useCallback, useEffect, useMemo, useState } from "react"; 8 | import { useDebounce, useDebouncedCallback } from "use-debounce"; 9 | 10 | // custom components 11 | import NoteOptionsModal from "../../components/Modals/NoteOptions"; 12 | import Button from "../../components/Button"; 13 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 14 | import { faMarkdown } from "@fortawesome/free-brands-svg-icons"; 15 | 16 | // Markdown and code editing 17 | import ReactMarkdown from "react-markdown"; 18 | import remarkGfm from "remark-gfm"; 19 | import CodeMirror, { type BasicSetupOptions } from "@uiw/react-codemirror"; 20 | import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; 21 | import { languages } from "@codemirror/language-data"; 22 | import { EditorView, keymap, type ViewUpdate } from "@codemirror/view"; 23 | // import { Extension } from "@codemirror/state"; 24 | 25 | // codemirror themes 26 | import { tokyoNightDayInit } from "@uiw/codemirror-theme-tokyo-night-day"; 27 | import { auraInit } from "@uiw/codemirror-theme-aura"; 28 | 29 | // syntax highlighting 30 | import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; 31 | import { 32 | oneDark, 33 | oneLight, 34 | } from "react-syntax-highlighter/dist/cjs/styles/prism"; 35 | 36 | // nextauth 37 | import { getServerAuthSession } from "../../server/auth"; 38 | import { type Session } from "next-auth"; 39 | import { useSession } from "next-auth/react"; 40 | 41 | // other 42 | import useThemeStore, { type ThemeType } from "../../stores/theme"; 43 | import { api } from "../../utils/api"; 44 | import { prisma } from "../../server/db"; 45 | import { type NoteWithTags } from "../.."; 46 | import { z } from "zod"; 47 | import { formatDate } from "../../utils/dates"; 48 | import DMP from "diff-match-patch"; 49 | import Navbar from "../../components/Navbar"; 50 | import Head from "next/head"; 51 | import { Tab } from "@headlessui/react"; 52 | 53 | // editor customization 54 | const customDarkTheme = auraInit({ 55 | settings: { background: "#00000000" }, 56 | }); 57 | const customLightTheme = tokyoNightDayInit({ 58 | settings: { background: "#00000000" }, 59 | }); 60 | 61 | const editorOptions: BasicSetupOptions = { 62 | // lineNumbers: false, 63 | // foldGutter: false, 64 | // syntaxHighlighting: true, 65 | }; 66 | 67 | const MarkdownPreview = ({ 68 | editorContent, 69 | theme, 70 | }: { 71 | editorContent: string; 72 | theme: ThemeType; 73 | }) => { 74 | return ( 75 | 88 | {String(children).replace(/\n$/, "")} 89 | 90 | ) : ( 91 | 92 | {children} 93 | 94 | ); 95 | }, 96 | }} 97 | > 98 | {editorContent} 99 | 100 | ); 101 | }; 102 | 103 | const MemoedMarkdownPreview = memo(MarkdownPreview); 104 | 105 | function TextEditor({ 106 | initialContent, 107 | setEditorContent, 108 | debouncedAutoSave, 109 | }: { 110 | initialContent: string; 111 | setEditorContent: (newContent: string) => void; 112 | debouncedAutoSave: () => void; 113 | }) { 114 | const { theme } = useThemeStore(); 115 | 116 | const onEditorChange = useCallback( 117 | (value: string, viewUpdate: ViewUpdate) => { 118 | setEditorContent(value); 119 | debouncedAutoSave(); 120 | }, 121 | [] 122 | ); 123 | 124 | return ( 125 | 137 | ); 138 | } 139 | 140 | const MemoedTextEditor = memo(TextEditor); 141 | 142 | // Codemirror text editor panel 143 | function EditorPanel({ 144 | note, 145 | mutationLoading, 146 | initialContent, 147 | previewOpen, 148 | setEditorContent, 149 | setModalOpen, 150 | setPreviewOpen, 151 | saveNote, 152 | debouncedAutoSave, 153 | }: { 154 | note: NoteWithTags; 155 | mutationLoading: boolean; 156 | initialContent: string; 157 | previewOpen: boolean; 158 | setEditorContent: (value: string) => void; 159 | setModalOpen: (newModalOpen: boolean) => void; 160 | setPreviewOpen: (newPreviewOpen: boolean) => void; 161 | saveNote: () => void; 162 | debouncedAutoSave: () => void; 163 | }) { 164 | return ( 165 |
166 | {/* Top bar */} 167 |
168 | {/* Top bar title */} 169 |
170 | 171 | Editor 172 |
173 | 174 | {/* Top bar buttons */} 175 |
176 | 177 | Last edited {formatDate(note.lastUpdated)} 178 | 179 |
180 |
225 |
226 |
227 | 228 | 233 |
234 | ); 235 | } 236 | 237 | const MemoedEditorPanel = memo(EditorPanel); 238 | 239 | // React Markdown preview panel 240 | function PreviewPanel({ 241 | debouncedEditorContent, 242 | }: { 243 | debouncedEditorContent: string; 244 | }) { 245 | const { theme } = useThemeStore(); 246 | 247 | return ( 248 |
249 | {/* Topbar */} 250 |
251 | 252 | Preview 253 |
254 | {/* Markdown Preview */} 255 |
256 | 260 |
261 |
262 | ); 263 | } 264 | 265 | const NotePage = ( 266 | props: InferGetServerSidePropsType 267 | ) => { 268 | // nextAuth 269 | const session = useSession().data as Session; 270 | 271 | // trpc 272 | const noteQuery = api.notes.getSingle.useQuery({ id: props.noteId }); 273 | const noteContentMutation = api.notes.updateContent.useMutation(); 274 | const tagsQuery = api.tags.getAll.useQuery(); 275 | const note = noteQuery.data; 276 | const tags = tagsQuery.data; 277 | const utils = api.useContext(); 278 | 279 | // diff-match-patch 280 | const dmp = useMemo(() => { 281 | return new DMP.diff_match_patch(); 282 | }, []); 283 | 284 | // editor state 285 | const [prevEditorContent, setPrevEditorContent] = useState(props.content); 286 | const [editorContent, setEditorContent] = useState(props.content); 287 | const [debouncedEditorContent] = useDebounce(editorContent, 500); 288 | const [previewOpen, setPreviewOpen] = useState(true); 289 | const [modalOpen, setModalOpen] = useState(false); 290 | 291 | // auto-saving with input debouncing 292 | const debouncedAutoSave = useDebouncedCallback(() => { 293 | saveNote(); 294 | }, 2000); 295 | 296 | // note saving function 297 | const saveNote = useCallback(() => { 298 | if (!note || !tags) return; 299 | const patches = dmp.patch_make(prevEditorContent, editorContent); 300 | noteContentMutation.mutate( 301 | { id: props.noteId, patches }, 302 | { 303 | onSuccess: (updatedNote, variables, context) => { 304 | void utils.notes.getSingle.invalidate({ id: variables.id }); 305 | setPrevEditorContent(editorContent); 306 | }, 307 | } 308 | ); 309 | }, [ 310 | dmp, 311 | editorContent, 312 | note, 313 | noteContentMutation, 314 | prevEditorContent, 315 | props.noteId, 316 | tags, 317 | utils.notes.getSingle, 318 | ]); 319 | 320 | // shortcut handler 321 | useEffect(() => { 322 | const handler = (e: KeyboardEvent) => { 323 | if (e.ctrlKey && e.key === "s") { 324 | e.preventDefault(); 325 | if (noteContentMutation.isLoading) return; 326 | saveNote(); 327 | } 328 | }; 329 | 330 | document.addEventListener("keydown", handler); 331 | return () => document.removeEventListener("keydown", handler); 332 | }, [noteContentMutation.isLoading, saveNote]); 333 | 334 | return ( 335 |
336 | 337 | 338 | {note 339 | ? `LuccaNotes • ${note.title}` 340 | : `LuccaNotes • Loading note...`} 341 | 342 | 343 | 344 | 345 | 346 | {/* Desktop */} 347 |
348 | {!note || !tags ? ( 349 | // Loading state 350 | <> 351 | {/* Editor */} 352 |
353 | 357 |
358 | {/* Markdown preview */} 359 |
360 | 364 |
365 | 366 | ) : ( 367 | // Loaded 368 | <> 369 | {/* Editor Panel */} 370 | 381 | 382 | {/* Preview Panel */} 383 | {previewOpen && ( 384 | 385 | )} 386 | 387 | )} 388 |
389 | 390 | {/* Mobile */} 391 |
392 | 393 |
394 | {/* Tabs */} 395 | 396 | 397 | Editor 398 | 399 | 400 | 401 | Preview 402 | 403 | 404 | 405 | {/* Buttons */} 406 | {note && tags && ( 407 |
408 |
432 | )} 433 |
434 | 435 | {!note || !tags ? ( 436 | // Loading state 437 | <> 438 | {/* Editor */} 439 | 440 | 444 | 445 | 446 | {/* Markdown preview */} 447 | 448 | 452 | 453 | 454 | ) : ( 455 | <> 456 | 457 | 468 | 469 | 470 | 471 | 474 | 475 | 476 | )} 477 | 478 |
479 |
480 | 481 | {/* Modal */} 482 | {modalOpen && note && tags && ( 483 | 489 | )} 490 |
491 | ); 492 | }; 493 | 494 | export const getServerSideProps = async ( 495 | context: GetServerSidePropsContext 496 | ) => { 497 | // check if user is logged in 498 | const session = await getServerAuthSession(context); 499 | if (!session) { 500 | return { 501 | redirect: { 502 | destination: "/", 503 | permanent: false, 504 | }, 505 | }; 506 | } 507 | 508 | // parse note ID 509 | const noteIdParser = z.string().safeParse(context.query.id); 510 | if (!noteIdParser.success) { 511 | return { 512 | redirect: { 513 | destination: "/404", 514 | permanent: false, 515 | }, 516 | }; 517 | } 518 | 519 | // check if this note actually exists 520 | const note = await prisma.note.findUnique({ 521 | where: { id: noteIdParser.data }, 522 | select: { id: true, userId: true, content: true }, 523 | }); 524 | 525 | if (!note) { 526 | return { 527 | redirect: { 528 | destination: "/404", 529 | permanent: false, 530 | }, 531 | }; 532 | } 533 | 534 | // check that user owns this note 535 | if (note.userId !== session.user.id) { 536 | return { 537 | redirect: { 538 | destination: "/404", 539 | permanent: false, 540 | }, 541 | }; 542 | } 543 | 544 | // Pass data to the page via props 545 | return { props: { session, noteId: note.id, content: note.content } }; 546 | }; 547 | 548 | export default NotePage; 549 | -------------------------------------------------------------------------------- /src/pages/notes/index.tsx: -------------------------------------------------------------------------------- 1 | // t3 imports 2 | import Head from "next/head"; 3 | import Link from "next/link"; 4 | import { signIn, signOut, useSession } from "next-auth/react"; 5 | import { type Session } from "next-auth"; 6 | import { api } from "../../utils/api"; 7 | import { 8 | GetServerSideProps, 9 | GetServerSidePropsContext, 10 | InferGetServerSidePropsType, 11 | type NextPage, 12 | } from "next"; 13 | import { useMemo, useState } from "react"; 14 | 15 | // package imports 16 | import { nanoid } from "nanoid"; 17 | 18 | // custom components 19 | import PageLayout from "../../components/Layouts/Page"; 20 | import NoteCard from "../../components/NoteCard"; 21 | import Button from "../../components/Button"; 22 | import { type TagColor } from "../../components/TagPill"; 23 | import SearchBar, { type SortField } from "../../components/SearchBar"; 24 | 25 | import ManageTagsModal from "../../components/Modals/ManageTags"; 26 | import NoteOptionsModal from "../../components/Modals/NoteOptions"; 27 | import CreateNoteModal from "../../components/Modals/CreateNote"; 28 | import { getServerAuthSession } from "../../server/auth"; 29 | import { Note, Prisma, Tag } from "@prisma/client"; 30 | import { prisma } from "../../server/db"; 31 | import { NoteWithTags } from "../.."; 32 | import useSearchStore from "../../stores/search"; 33 | import { Spinner } from "phosphor-react"; 34 | import LoadingNoteCard from "../../components/LoadingNoteCard"; 35 | 36 | function sortNotes( 37 | notes: NoteWithTags[], 38 | sortField: SortField, 39 | sortOrder: "asc" | "desc" 40 | ) { 41 | switch (sortField) { 42 | case "title": 43 | return notes // 44 | .sort((n1, n2) => { 45 | if (n1.title < n2.title) return sortOrder === "asc" ? -1 : 1; 46 | if (n1.title > n2.title) return sortOrder === "asc" ? 1 : -1; 47 | return 0; 48 | }); 49 | case "createdAt": 50 | return notes // 51 | .sort((n1, n2) => { 52 | return sortOrder === "asc" 53 | ? n1.createdAt.getTime() - n2.createdAt.getTime() 54 | : n2.createdAt.getTime() - n1.createdAt.getTime(); 55 | }); 56 | case "lastUpdated": 57 | return notes // 58 | .sort((n1, n2) => { 59 | return sortOrder === "asc" 60 | ? n1.lastUpdated.getTime() - n2.lastUpdated.getTime() 61 | : n2.lastUpdated.getTime() - n1.lastUpdated.getTime(); 62 | }); 63 | default: 64 | throw new Error("Tried to process notes, got to switch-case dead end"); 65 | } 66 | } 67 | 68 | function NotesPage( 69 | props: InferGetServerSidePropsType 70 | ) { 71 | // next auth 72 | const session = useSession().data as Session; 73 | 74 | // dummy data 75 | const fakeNote: NoteWithTags = { 76 | createdAt: new Date(), 77 | id: nanoid(), 78 | lastUpdated: new Date(), 79 | tags: [ 80 | { 81 | color: "green", 82 | id: nanoid(), 83 | createdAt: new Date(), 84 | label: "test tag", 85 | userId: session.user.id, 86 | }, 87 | { 88 | color: "green", 89 | id: nanoid(), 90 | createdAt: new Date(), 91 | label: "test tag", 92 | userId: session.user.id, 93 | }, 94 | { 95 | color: "green", 96 | id: nanoid(), 97 | createdAt: new Date(), 98 | label: "test tag", 99 | userId: session.user.id, 100 | }, 101 | ], 102 | title: "Fake Note", 103 | userId: session.user.id, 104 | }; 105 | 106 | // trpc 107 | const tagsQuery = api.tags.getAll.useQuery(undefined, {}); 108 | const notesQuery = api.notes.getAll.useQuery(); 109 | const notes = useMemo(() => { 110 | return notesQuery.data ?? []; 111 | }, [notesQuery.data]); 112 | const tags = tagsQuery.data ?? []; 113 | 114 | // modals state 115 | const [tagModalOpen, setTagModalOpen] = useState(false); 116 | const [createNoteModalOpen, setCreateNoteModalOpen] = useState(false); 117 | 118 | // zustand 119 | const { searchInput, sortField, sortOrder, selectedTagIds } = 120 | useSearchStore(); 121 | 122 | // compute visible notes 123 | const visibleNotes = useMemo(() => { 124 | // sort notes 125 | const sortedNotes = sortNotes(notes, sortField, sortOrder); 126 | 127 | return ( 128 | sortedNotes 129 | // match selected tags 130 | .filter((note) => { 131 | // pass all notes if there are no selected tags 132 | if (selectedTagIds.length === 0) return true; 133 | // check if `selectedTagIds` is a subset of `noteTagIds` 134 | const noteTagIds = note.tags.map((t) => t.id); 135 | for (const selectedTagId of selectedTagIds) { 136 | if (!noteTagIds.includes(selectedTagId)) return false; 137 | } 138 | return true; 139 | }) 140 | // match note title 141 | .filter((note) => 142 | note.title.toLowerCase().includes(searchInput.toLowerCase()) 143 | ) 144 | ); 145 | }, [searchInput, sortField, sortOrder, notes, selectedTagIds]); 146 | return ( 147 | 148 | 149 | LuccaNotes • {session.user.name}'s notes 150 | 151 | 152 | {/* Top row */} 153 |
154 | 155 |
156 |
177 |
178 | 179 | {/* Note card container */} 180 | {notesQuery.isLoading || false ? ( 181 |
182 | 183 | 184 | 185 | 186 | 187 | 188 |
189 | ) : visibleNotes.length ? ( 190 |
191 | {visibleNotes.map((note, index) => ( 192 | = 3 197 | } 198 | dateType={sortField === "createdAt" ? "createdAt" : "lastUpdated"} 199 | tags={tags} 200 | /> 201 | ))} 202 |
203 | ) : selectedTagIds.length === 0 && !searchInput ? ( 204 |

205 | No notes to see here. 206 |
207 | Try clicking{" "} 208 | New note to 209 | create one! 210 |

211 | ) : ( 212 |

213 | No notes match these filters. 214 |
215 | Try clicking{" "} 216 | New note to 217 | create one! 218 |

219 | )} 220 | 221 | {/* Mobile button container */} 222 |
223 |
248 | {/* Modals */} 249 | {tagModalOpen && ( 250 | 255 | )} 256 | {/* {false && ( 257 | setSelectedNoteId(null)} 260 | // setSelectedNoteId={setSelectedNoteId} 261 | // selectedNote={notes?.find((n) => n.id === selectedNoteId) ?? null} 262 | tags={tags} 263 | /> 264 | )} */} 265 | {createNoteModalOpen && ( 266 | 271 | )} 272 |
273 | ); 274 | } 275 | 276 | export const getServerSideProps = async ( 277 | context: GetServerSidePropsContext 278 | ) => { 279 | const session = await getServerAuthSession(context); 280 | 281 | if (!session) { 282 | return { 283 | redirect: { 284 | destination: "/", 285 | permanent: false, 286 | }, 287 | }; 288 | } 289 | 290 | // Pass data to the page via props 291 | return { props: { session } }; 292 | }; 293 | 294 | export default NotesPage; 295 | -------------------------------------------------------------------------------- /src/prisma-testing.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from "./server/db"; 2 | import { Prisma } from "@prisma/client"; 3 | 4 | // local Postgres 5 | const userId = "clef30pju0000wgem8fl2rm6u"; 6 | 7 | // supabase 8 | // const userId = "clehia9kk0000wg764sxumbfz"; 9 | 10 | // railway 11 | // const userId = "clekgk5pq0000wgnbm6xere11"; 12 | 13 | const tags: Prisma.TagCreateManyArgs["data"] = [ 14 | { 15 | label: "Coding", 16 | color: "red", 17 | userId, 18 | }, 19 | { 20 | label: "Music", 21 | color: "sky", 22 | userId, 23 | }, 24 | { 25 | label: "School", 26 | color: "yellow", 27 | userId, 28 | }, 29 | { 30 | label: "General", 31 | color: "lightGray", 32 | userId, 33 | }, 34 | { 35 | label: "Tasks", 36 | color: "violet", 37 | userId, 38 | }, 39 | { 40 | label: "Work", 41 | color: "green", 42 | userId, 43 | }, 44 | { 45 | label: "Reading", 46 | color: "darkGray", 47 | userId, 48 | }, 49 | ]; 50 | 51 | // const notesList: Note[] = [ 52 | // { 53 | // id: nanoid(), 54 | // title: "My First Note", 55 | // lastUpdated: subtractSeconds(defaultDate, 20), 56 | // createdAt: subtractSeconds(defaultDate, 30), 57 | // tags: [tags.coding, tags.music, tags.work, tags.general], 58 | // }, 59 | // { 60 | // id: nanoid(), 61 | // title: "Second Note", 62 | // lastUpdated: subtractSeconds(defaultDate, 30), 63 | // createdAt: subtractSeconds(defaultDate, 20), 64 | // tags: [tags.work, tags.general, tags.coding, tags.music], 65 | // }, 66 | // { 67 | // id: nanoid(), 68 | // title: "Note Number 3", 69 | // lastUpdated: defaultDate, 70 | // createdAt: subtractSeconds(defaultDate, 10), 71 | // tags: [tags.school, tags.tasks, tags.work, tags.general, tags.coding], 72 | // }, 73 | // { 74 | // id: nanoid(), 75 | // title: "The 4th Note", 76 | // lastUpdated: subtractSeconds(defaultDate, 10), 77 | // createdAt: defaultDate, 78 | // tags: [ 79 | // tags.coding, 80 | // tags.tasks, 81 | // tags.work, 82 | // tags.general, 83 | // tags.music, 84 | // tags.school, 85 | // ], 86 | // }, 87 | // ]; 88 | 89 | const notes: Prisma.NoteCreateArgs["data"][] = [ 90 | // { 91 | // } 92 | ]; 93 | 94 | // { 95 | // label: "Coding", 96 | // color: "RED", 97 | // userId, 98 | // }, 99 | 100 | async function getUsers() { 101 | const users = await prisma.user.findMany(); 102 | console.log(`Got ${users.length} users:`, users); 103 | } 104 | 105 | // async function deleteTags() { 106 | // const deletedTags = await prisma.tag.deleteMany(); 107 | // console.log(`Deleted ${deletedTags.count} tags.`); 108 | // } 109 | 110 | async function seedTags() { 111 | const createdTags = await prisma.tag.createMany({ data: tags }); 112 | // console.log(`Created tag:`, createdTag); 113 | console.log(`Created ${createdTags.count} tags`); 114 | } 115 | 116 | async function getTags() { 117 | const tags = await prisma.tag.findMany(); 118 | console.log(`Got ${tags.length} tags:`, tags); 119 | } 120 | 121 | async function deleteTags() { 122 | const deletedTags = await prisma.tag.deleteMany(); 123 | console.log(`Deleted ${deletedTags.count} tags.`); 124 | } 125 | 126 | async function seedNotes() { 127 | const notes: Prisma.NoteCreateManyArgs["data"] = []; 128 | 129 | for (let i = 1; i <= 10; i++) { 130 | const note: Prisma.NoteCreateManyInput = { 131 | title: `Auto Note #${i}`, 132 | userId, 133 | }; 134 | notes.push(note); 135 | } 136 | 137 | const createdNotes = await prisma.note.createMany({ data: notes }); 138 | console.log(`Created ${createdNotes.count} notes`); 139 | } 140 | 141 | async function getNotes() { 142 | const notes = await prisma.note.findMany(); 143 | console.log(`Got ${notes.length} notes:`, notes); 144 | } 145 | 146 | async function deleteNotes() { 147 | const deletedNotes = await prisma.note.deleteMany({ 148 | where: { 149 | title: { contains: "Auto Note" }, 150 | }, 151 | }); 152 | console.log(`Deleted ${deletedNotes.count} Notes.`); 153 | } 154 | 155 | async function main() { 156 | // users 157 | // void (await getUsers()); 158 | // tags 159 | // void (await seedTags()); 160 | // void (await getTags()); 161 | // void (await deleteTags()); 162 | // notes 163 | void (await seedNotes()); 164 | // void (await getNotes()); 165 | // void (await deleteNotes()); 166 | } 167 | 168 | void main(); 169 | -------------------------------------------------------------------------------- /src/server/api/root.ts: -------------------------------------------------------------------------------- 1 | import { createTRPCRouter } from "./trpc"; 2 | 3 | // router imports 4 | import { exampleRouter } from "./routers/example"; 5 | import { notesRouter } from "./routers/notes"; 6 | import { tagsRouter } from "./routers/tags"; 7 | 8 | /** 9 | * This is the primary router for your server. 10 | * 11 | * All routers added in /api/routers should be manually added here 12 | */ 13 | export const appRouter = createTRPCRouter({ 14 | example: exampleRouter, 15 | notes: notesRouter, 16 | tags: tagsRouter, 17 | }); 18 | 19 | // export type definition of API 20 | export type AppRouter = typeof appRouter; 21 | -------------------------------------------------------------------------------- /src/server/api/routers/example.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | import { createTRPCRouter, publicProcedure, protectedProcedure } from "../trpc"; 4 | 5 | export const exampleRouter = createTRPCRouter({ 6 | hello: publicProcedure 7 | .input(z.object({ text: z.string() })) 8 | .query(({ input }) => { 9 | return { 10 | greeting: `Hello ${input.text}`, 11 | }; 12 | }), 13 | 14 | getAll: publicProcedure.query(({ ctx }) => { 15 | return ctx.prisma.example.findMany(); 16 | }), 17 | 18 | getSecretMessage: protectedProcedure.query(() => { 19 | return "you can now see this secret message!"; 20 | }), 21 | }); 22 | -------------------------------------------------------------------------------- /src/server/api/routers/notes.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { createTRPCRouter, protectedProcedure } from "../trpc"; 3 | import DMP from "diff-match-patch"; 4 | import { TRPCError } from "@trpc/server"; 5 | import { Prisma, PrismaClient } from "@prisma/client"; 6 | import { Session } from "next-auth"; 7 | 8 | const diffSchema = z.tuple([z.number(), z.string()]); 9 | const patchesSchema: z.ZodType<(new () => DMP.patch_obj)[]> = z.any(); 10 | 11 | // get current note content, check ownership 12 | async function getFullNote( 13 | prisma: PrismaClient, 14 | session: Session, 15 | noteId: string 16 | ) { 17 | const note = await prisma.note.findFirst({ 18 | where: { id: noteId, userId: session.user.id }, 19 | include: { tags: true }, 20 | }); 21 | if (!note) return null; 22 | return note; 23 | } 24 | 25 | export const notesRouter = createTRPCRouter({ 26 | getSingle: protectedProcedure 27 | .input(z.object({ id: z.string().cuid() })) 28 | .query(({ ctx, input }) => { 29 | return ctx.prisma.note.findFirst({ 30 | where: { id: input.id, userId: ctx.session.user.id }, 31 | select: { 32 | id: true, 33 | createdAt: true, 34 | lastUpdated: true, 35 | title: true, 36 | userId: true, 37 | tags: { orderBy: { createdAt: "asc" } }, 38 | }, 39 | }); 40 | }), 41 | 42 | getSingleContent: protectedProcedure 43 | .input(z.object({ id: z.string().cuid() })) 44 | .query(async ({ ctx, input }) => { 45 | return ctx.prisma.note.findFirst({ 46 | where: { id: input.id, userId: ctx.session.user.id }, 47 | select: { content: true, userId: true }, 48 | }); 49 | }), 50 | 51 | getAll: protectedProcedure.query(({ ctx }) => { 52 | return ctx.prisma.note.findMany({ 53 | where: { userId: ctx.session.user.id }, 54 | select: { 55 | id: true, 56 | createdAt: true, 57 | lastUpdated: true, 58 | title: true, 59 | userId: true, 60 | tags: { orderBy: { createdAt: "asc" } }, 61 | }, 62 | }); 63 | }), 64 | 65 | create: protectedProcedure 66 | .input(z.object({ title: z.string(), tagIds: z.string().cuid().array() })) 67 | .mutation(async ({ ctx, input }) => { 68 | return ctx.prisma.note.create({ 69 | data: { 70 | title: input.title, 71 | tags: { 72 | connect: input.tagIds.map((tagId) => ({ id: tagId })), 73 | }, 74 | userId: ctx.session.user.id, 75 | }, 76 | select: { 77 | id: true, 78 | createdAt: true, 79 | lastUpdated: true, 80 | title: true, 81 | userId: true, 82 | tags: { orderBy: { createdAt: "asc" } }, 83 | }, 84 | }); 85 | }), 86 | 87 | delete: protectedProcedure 88 | .input(z.object({ id: z.string().cuid() })) 89 | .mutation(async ({ ctx, input }) => { 90 | const note = await ctx.prisma.note.findFirst({ 91 | where: { id: input.id, userId: ctx.session.user.id }, 92 | }); 93 | 94 | if (!note) throw new TRPCError({ code: "NOT_FOUND" }); 95 | 96 | return await ctx.prisma.note.delete({ 97 | where: { id: input.id }, 98 | select: { 99 | id: true, 100 | createdAt: true, 101 | lastUpdated: true, 102 | title: true, 103 | userId: true, 104 | tags: { orderBy: { createdAt: "asc" } }, 105 | }, 106 | }); 107 | }), 108 | 109 | rename: protectedProcedure 110 | .input(z.object({ id: z.string().cuid(), newTitle: z.string() })) 111 | .mutation(async ({ ctx, input }) => { 112 | const note = await ctx.prisma.note.findFirst({ 113 | where: { id: input.id, userId: ctx.session.user.id }, 114 | }); 115 | 116 | if (!note) throw new TRPCError({ code: "NOT_FOUND" }); 117 | 118 | return ctx.prisma.note.update({ 119 | where: { id: input.id }, 120 | data: { title: input.newTitle }, 121 | select: { 122 | id: true, 123 | createdAt: true, 124 | lastUpdated: true, 125 | title: true, 126 | userId: true, 127 | tags: { orderBy: { createdAt: "asc" } }, 128 | }, 129 | }); 130 | }), 131 | 132 | addTag: protectedProcedure 133 | .input(z.object({ id: z.string().cuid(), tagId: z.string().cuid() })) 134 | .mutation(async ({ ctx, input }) => { 135 | const note = await ctx.prisma.note.findFirst({ 136 | where: { id: input.id, userId: ctx.session.user.id }, 137 | }); 138 | 139 | if (!note) throw new TRPCError({ code: "NOT_FOUND" }); 140 | 141 | return ctx.prisma.note.update({ 142 | where: { id: input.id }, 143 | data: { 144 | tags: { connect: { id: input.tagId } }, 145 | }, 146 | select: { 147 | id: true, 148 | createdAt: true, 149 | lastUpdated: true, 150 | title: true, 151 | userId: true, 152 | tags: { orderBy: { createdAt: "asc" } }, 153 | }, 154 | }); 155 | }), 156 | 157 | removeTag: protectedProcedure 158 | .input(z.object({ id: z.string().cuid(), tagId: z.string().cuid() })) 159 | .mutation(async ({ ctx, input }) => { 160 | const note = await ctx.prisma.note.findFirst({ 161 | where: { id: input.id, userId: ctx.session.user.id }, 162 | }); 163 | 164 | if (!note) throw new TRPCError({ code: "NOT_FOUND" }); 165 | 166 | return ctx.prisma.note.update({ 167 | where: { id: input.id }, 168 | data: { 169 | tags: { disconnect: { id: input.tagId } }, 170 | }, 171 | select: { 172 | id: true, 173 | createdAt: true, 174 | lastUpdated: true, 175 | title: true, 176 | userId: true, 177 | tags: { orderBy: { createdAt: "asc" } }, 178 | }, 179 | }); 180 | }), 181 | 182 | updateContent: protectedProcedure 183 | .input( 184 | z.object({ 185 | id: z.string().cuid(), 186 | patches: patchesSchema, 187 | }) 188 | ) 189 | .mutation(async ({ ctx, input }) => { 190 | console.log("Got patches:", input.patches); 191 | 192 | const note = await getFullNote(ctx.prisma, ctx.session, input.id); 193 | if (!note) throw new TRPCError({ code: "NOT_FOUND" }); 194 | 195 | // compute and apply text patches 196 | const dmp = new DMP.diff_match_patch(); 197 | 198 | const [newContent, results] = dmp.patch_apply( 199 | input.patches, 200 | note.content 201 | ); 202 | 203 | // update note with new text content 204 | const updatedNote = await ctx.prisma.note.update({ 205 | where: { id: input.id }, 206 | data: { content: newContent, lastUpdated: new Date() }, 207 | include: { tags: true }, 208 | }); 209 | return; 210 | }), 211 | }); 212 | -------------------------------------------------------------------------------- /src/server/api/routers/tags.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { createTRPCRouter, protectedProcedure } from "../trpc"; 3 | import { Color } from "@prisma/client"; 4 | 5 | export const tagsRouter = createTRPCRouter({ 6 | getAll: protectedProcedure.query(({ ctx }) => { 7 | return ctx.prisma.tag.findMany({ 8 | where: { userId: ctx.session.user.id }, 9 | orderBy: { createdAt: "asc" }, 10 | }); 11 | }), 12 | create: protectedProcedure 13 | .input(z.object({ label: z.string(), color: z.nativeEnum(Color) })) 14 | .mutation(({ ctx, input }) => { 15 | console.log("Got mutation!", input); 16 | return ctx.prisma.tag.create({ 17 | data: { 18 | label: input.label, 19 | color: input.color, 20 | userId: ctx.session.user.id, 21 | }, 22 | }); 23 | }), 24 | delete: protectedProcedure 25 | .input(z.object({ id: z.string().cuid() })) 26 | .mutation(({ ctx, input }) => { 27 | return ctx.prisma.tag.delete({ where: { id: input.id } }); 28 | }), 29 | }); 30 | -------------------------------------------------------------------------------- /src/server/api/trpc.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS: 3 | * 1. You want to modify request context (see Part 1). 4 | * 2. You want to create a new middleware or type of procedure (see Part 3). 5 | * 6 | * tl;dr - This is where all the tRPC server stuff is created and plugged in. 7 | * The pieces you will need to use are documented accordingly near the end. 8 | */ 9 | 10 | /** 11 | * 1. CONTEXT 12 | * 13 | * This section defines the "contexts" that are available in the backend API. 14 | * 15 | * These allow you to access things when processing a request, like the 16 | * database, the session, etc. 17 | */ 18 | import { type CreateNextContextOptions } from "@trpc/server/adapters/next"; 19 | import { type Session } from "next-auth"; 20 | 21 | import { getServerAuthSession } from "../auth"; 22 | import { prisma } from "../db"; 23 | 24 | type CreateContextOptions = { 25 | session: Session | null; 26 | }; 27 | 28 | /** 29 | * This helper generates the "internals" for a tRPC context. If you need to use 30 | * it, you can export it from here. 31 | * 32 | * Examples of things you may need it for: 33 | * - testing, so we don't have to mock Next.js' req/res 34 | * - tRPC's `createSSGHelpers`, where we don't have req/res 35 | * 36 | * @see https://create.t3.gg/en/usage/trpc#-servertrpccontextts 37 | */ 38 | const createInnerTRPCContext = (opts: CreateContextOptions) => { 39 | return { 40 | session: opts.session, 41 | prisma, 42 | }; 43 | }; 44 | 45 | /** 46 | * This is the actual context you will use in your router. It will be used to 47 | * process every request that goes through your tRPC endpoint. 48 | * 49 | * @see https://trpc.io/docs/context 50 | */ 51 | export const createTRPCContext = async (opts: CreateNextContextOptions) => { 52 | const { req, res } = opts; 53 | 54 | // Get the session from the server using the getServerSession wrapper function 55 | const session = await getServerAuthSession({ req, res }); 56 | 57 | return createInnerTRPCContext({ 58 | session, 59 | }); 60 | }; 61 | 62 | /** 63 | * 2. INITIALIZATION 64 | * 65 | * This is where the tRPC API is initialized, connecting the context and 66 | * transformer. 67 | */ 68 | import { initTRPC, TRPCError } from "@trpc/server"; 69 | import superjson from "superjson"; 70 | 71 | const t = initTRPC.context().create({ 72 | transformer: superjson, 73 | errorFormatter({ shape }) { 74 | return shape; 75 | }, 76 | }); 77 | 78 | /** 79 | * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT) 80 | * 81 | * These are the pieces you use to build your tRPC API. You should import these 82 | * a lot in the "/src/server/api/routers" directory. 83 | */ 84 | 85 | /** 86 | * This is how you create new routers and sub-routers in your tRPC API. 87 | * 88 | * @see https://trpc.io/docs/router 89 | */ 90 | export const createTRPCRouter = t.router; 91 | 92 | /** 93 | * Public (unauthenticated) procedure 94 | * 95 | * This is the base piece you use to build new queries and mutations on your 96 | * tRPC API. It does not guarantee that a user querying is authorized, but you 97 | * can still access user session data if they are logged in. 98 | */ 99 | export const publicProcedure = t.procedure; 100 | 101 | /** 102 | * Reusable middleware that enforces users are logged in before running the 103 | * procedure. 104 | */ 105 | const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { 106 | if (!ctx.session || !ctx.session.user) { 107 | throw new TRPCError({ code: "UNAUTHORIZED" }); 108 | } 109 | return next({ 110 | ctx: { 111 | // infers the `session` as non-nullable 112 | session: { ...ctx.session, user: ctx.session.user }, 113 | }, 114 | }); 115 | }); 116 | 117 | /** 118 | * Protected (authenticated) procedure 119 | * 120 | * If you want a query or mutation to ONLY be accessible to logged in users, use 121 | * this. It verifies the session is valid and guarantees `ctx.session.user` is 122 | * not null. 123 | * 124 | * @see https://trpc.io/docs/procedures 125 | */ 126 | export const protectedProcedure = t.procedure.use(enforceUserIsAuthed); 127 | -------------------------------------------------------------------------------- /src/server/auth.ts: -------------------------------------------------------------------------------- 1 | import type { GetServerSidePropsContext } from "next"; 2 | import { 3 | getServerSession, 4 | type NextAuthOptions, 5 | type DefaultSession, 6 | } from "next-auth"; 7 | import DiscordProvider from "next-auth/providers/discord"; 8 | import GoogleProvider from "next-auth/providers/google"; 9 | import { PrismaAdapter } from "@next-auth/prisma-adapter"; 10 | import { env } from "../env/server.mjs"; 11 | import { prisma } from "./db"; 12 | 13 | /** 14 | * Module augmentation for `next-auth` types. 15 | * Allows us to add custom properties to the `session` object and keep type 16 | * safety. 17 | * 18 | * @see https://next-auth.js.org/getting-started/typescript#module-augmentation 19 | **/ 20 | declare module "next-auth" { 21 | interface Session extends DefaultSession { 22 | user: { 23 | id: string; 24 | // ...other properties 25 | // role: UserRole; 26 | } & DefaultSession["user"]; 27 | } 28 | 29 | // interface User { 30 | // // ...other properties 31 | // // role: UserRole; 32 | // } 33 | } 34 | 35 | /** 36 | * Options for NextAuth.js used to configure adapters, providers, callbacks, 37 | * etc. 38 | * 39 | * @see https://next-auth.js.org/configuration/options 40 | **/ 41 | export const authOptions: NextAuthOptions = { 42 | callbacks: { 43 | session({ session, user }) { 44 | if (session.user) { 45 | session.user.id = user.id; 46 | // session.user.role = user.role; <-- put other properties on the session here 47 | } 48 | return session; 49 | }, 50 | }, 51 | adapter: PrismaAdapter(prisma), 52 | providers: [ 53 | DiscordProvider({ 54 | clientId: env.DISCORD_CLIENT_ID, 55 | clientSecret: env.DISCORD_CLIENT_SECRET, 56 | }), 57 | GoogleProvider({ 58 | clientId: env.GOOGLE_CLIENT_ID, 59 | clientSecret: env.GOOGLE_CLIENT_SECRET, 60 | }), 61 | /** 62 | * ...add more providers here 63 | * 64 | * Most other providers require a bit more work than the Discord provider. 65 | * For example, the GitHub provider requires you to add the 66 | * `refresh_token_expires_in` field to the Account model. Refer to the 67 | * NextAuth.js docs for the provider you want to use. Example: 68 | * @see https://next-auth.js.org/providers/github 69 | **/ 70 | ], 71 | debug: false, 72 | pages: { 73 | signIn: "/auth/signin", 74 | // error: "https://example.com", // Error code passed in query string as ?error= 75 | }, 76 | }; 77 | 78 | /** 79 | * Wrapper for `getServerSession` so that you don't need to import the 80 | * `authOptions` in every file. 81 | * 82 | * @see https://next-auth.js.org/configuration/nextjs 83 | **/ 84 | export const getServerAuthSession = (ctx: { 85 | req: GetServerSidePropsContext["req"]; 86 | res: GetServerSidePropsContext["res"]; 87 | }) => { 88 | return getServerSession(ctx.req, ctx.res, authOptions); 89 | }; 90 | -------------------------------------------------------------------------------- /src/server/db.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | import { env } from "../env/server.mjs"; 4 | 5 | const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }; 6 | 7 | export const prisma = 8 | globalForPrisma.prisma || 9 | new PrismaClient({ 10 | // log: 11 | // env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"], 12 | }); 13 | 14 | if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; 15 | -------------------------------------------------------------------------------- /src/stores/search.ts: -------------------------------------------------------------------------------- 1 | import { type Tag } from "@prisma/client"; 2 | import { create } from "zustand"; 3 | import { devtools } from "zustand/middleware"; 4 | 5 | export type ThemeType = "dark" | "light"; 6 | 7 | // Sort fields 8 | const sortFieldLabels = { 9 | title: "Title", 10 | createdAt: "Creation Date", 11 | lastUpdated: "Last Updated", 12 | } as const; 13 | type SortField = keyof typeof sortFieldLabels; 14 | 15 | interface ThemeStore { 16 | // search input 17 | searchInput: string; 18 | setSearchInput: (newSearchInput: string) => void; 19 | // sort field 20 | sortField: SortField; 21 | setSortField: (newSortField: SortField) => void; 22 | // sort order 23 | sortOrder: "asc" | "desc"; 24 | setSortOrder: (newSortOrder: "asc" | "desc") => void; 25 | // selected tags 26 | selectedTagIds: string[]; 27 | toggleSelectedTag: (tagId: string) => void; 28 | } 29 | 30 | const useSearchStore = create()( 31 | devtools( 32 | (set) => ({ 33 | // search input 34 | searchInput: "", 35 | setSearchInput: (newSearchInput) => set({ searchInput: newSearchInput }), 36 | // sort field 37 | sortField: "lastUpdated", 38 | setSortField: (newSortField) => set({ sortField: newSortField }), 39 | // sort order 40 | sortOrder: "desc", 41 | setSortOrder: (newSortOrder) => set({ sortOrder: newSortOrder }), 42 | // selected tags 43 | selectedTagIds: [], 44 | toggleSelectedTag: (tagId) => 45 | set((state) => { 46 | const newSelectedTags = state.selectedTagIds.includes(tagId) 47 | ? state.selectedTagIds.filter((id) => id !== tagId) 48 | : [...state.selectedTagIds, tagId]; 49 | return { selectedTagIds: newSelectedTags }; 50 | }), 51 | }), 52 | { 53 | name: "theme-storage", 54 | } 55 | ) 56 | ); 57 | 58 | export default useSearchStore; 59 | -------------------------------------------------------------------------------- /src/stores/theme.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { devtools, persist } from "zustand/middleware"; 3 | 4 | export type ThemeType = "dark" | "light"; 5 | 6 | interface ThemeStore { 7 | theme: ThemeType; 8 | setTheme: (newTheme: ThemeType) => void; 9 | } 10 | 11 | const useThemeStore = create()( 12 | devtools( 13 | persist( 14 | (set) => ({ 15 | theme: "light", 16 | setTheme: (newTheme) => set(() => ({ theme: newTheme })), 17 | }), 18 | { 19 | name: "theme-storage", 20 | } 21 | ) 22 | ) 23 | ); 24 | 25 | export default useThemeStore; 26 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /* codemirror editor */ 6 | .cm-content { 7 | font-family: monospace; 8 | font-size: 16px; 9 | border: none; 10 | outline: none; 11 | } 12 | 13 | .cm-wrap { 14 | height: 100%; 15 | } 16 | .cm-scroller { 17 | overflow: auto; 18 | } 19 | 20 | /* markdown preview */ 21 | .preview { 22 | @apply text-gray-800 dark:text-gray-200; 23 | } 24 | 25 | .preview h1, 26 | .preview h2 { 27 | @apply my-3 border-b-2 border-gray-300 pb-1 tracking-tight dark:border-gray-700; 28 | } 29 | 30 | .preview h3, 31 | .preview h4 { 32 | @apply mb-3 tracking-tight; 33 | } 34 | 35 | .preview h1 { 36 | @apply text-3xl font-bold; 37 | } 38 | 39 | .preview h2 { 40 | @apply text-2xl font-bold; 41 | } 42 | 43 | .preview h3 { 44 | @apply text-xl font-bold; 45 | } 46 | 47 | .preview h4 { 48 | @apply text-lg font-bold; 49 | } 50 | 51 | .preview p { 52 | @apply my-4; 53 | } 54 | 55 | .preview img { 56 | @apply mx-auto my-6 max-h-[600px] rounded-md; 57 | } 58 | 59 | .preview code { 60 | @apply rounded-md bg-gray-300 px-2 py-0.5 font-normal dark:bg-gray-700 dark:text-white; 61 | } 62 | 63 | .preview a, 64 | .preview a code { 65 | @apply text-blue-600 underline decoration-transparent hover:decoration-blue-600 dark:text-blue-400 dark:hover:decoration-blue-400; 66 | } 67 | 68 | .preview code[class*="language-"] { 69 | @apply px-0; 70 | } 71 | -------------------------------------------------------------------------------- /src/styles/markdown.module.css: -------------------------------------------------------------------------------- 1 | .cm-content { 2 | font-family: monospace; 3 | font-size: 14px; 4 | } 5 | -------------------------------------------------------------------------------- /src/utils/api.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This is the client-side entrypoint for your tRPC API. 3 | * It is used to create the `api` object which contains the Next.js 4 | * App-wrapper, as well as your type-safe React Query hooks. 5 | * 6 | * We also create a few inference helpers for input and output types 7 | */ 8 | import { httpBatchLink, loggerLink } from "@trpc/client"; 9 | import { createTRPCNext } from "@trpc/next"; 10 | import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server"; 11 | import superjson from "superjson"; 12 | 13 | import { type AppRouter } from "../server/api/root"; 14 | 15 | const getBaseUrl = () => { 16 | if (typeof window !== "undefined") return ""; // browser should use relative url 17 | if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; // SSR should use vercel url 18 | return `http://localhost:${process.env.PORT ?? 3000}`; // dev SSR should use localhost 19 | }; 20 | 21 | /** A set of type-safe react-query hooks for your tRPC API. */ 22 | export const api = createTRPCNext({ 23 | config() { 24 | return { 25 | /** 26 | * Transformer used for data de-serialization from the server. 27 | * 28 | * @see https://trpc.io/docs/data-transformers 29 | **/ 30 | transformer: superjson, 31 | 32 | /** 33 | * Links used to determine request flow from client to server. 34 | * 35 | * @see https://trpc.io/docs/links 36 | * */ 37 | links: [ 38 | loggerLink({ 39 | enabled: (opts) => 40 | process.env.NODE_ENV === "development" || 41 | (opts.direction === "down" && opts.result instanceof Error), 42 | }), 43 | httpBatchLink({ 44 | url: `${getBaseUrl()}/api/trpc`, 45 | }), 46 | ], 47 | }; 48 | }, 49 | /** 50 | * Whether tRPC should await queries when server rendering pages. 51 | * 52 | * @see https://trpc.io/docs/nextjs#ssr-boolean-default-false 53 | */ 54 | ssr: false, 55 | }); 56 | 57 | /** 58 | * Inference helper for inputs. 59 | * 60 | * @example type HelloInput = RouterInputs['example']['hello'] 61 | **/ 62 | export type RouterInputs = inferRouterInputs; 63 | 64 | /** 65 | * Inference helper for outputs. 66 | * 67 | * @example type HelloOutput = RouterOutputs['example']['hello'] 68 | **/ 69 | export type RouterOutputs = inferRouterOutputs; 70 | -------------------------------------------------------------------------------- /src/utils/dates.ts: -------------------------------------------------------------------------------- 1 | export function formatDate(date: Date): string { 2 | const nowInSeconds = Math.floor(Date.now() / 1000); 3 | const dateInSeconds = Math.floor(date.getTime() / 1000); 4 | 5 | const difference = nowInSeconds - dateInSeconds; 6 | let output = ``; 7 | if (difference < 60) { 8 | // Less than a minute has passed: 9 | // output = `${difference} ${difference === 1 ? "second" : "seconds"} ago`; 10 | output = "less than a minute ago"; 11 | } else if (difference < 3600) { 12 | // Less than an hour has passed: 13 | output = `${Math.floor(difference / 60)} ${ 14 | Math.floor(difference / 60) === 1 ? "minute" : "minutes" 15 | } ago`; 16 | } else if (difference < 86400) { 17 | // Less than a day has passed: 18 | output = `${Math.floor(difference / 3600)} ${ 19 | Math.floor(difference / 3600) === 1 ? "hour" : "hours" 20 | } ago`; 21 | } else if (difference < 2620800) { 22 | // Less than a month has passed: 23 | output = `${Math.floor(difference / 86400)} ${ 24 | Math.floor(difference / 86400) === 1 ? "day" : "days" 25 | } ago`; 26 | } else if (difference < 31449600) { 27 | // Less than a year has passed: 28 | output = `${Math.floor(difference / 2620800)} ${ 29 | Math.floor(difference / 2620800) === 1 ? "month" : "months" 30 | } ago`; 31 | } else { 32 | // More than a year has passed: 33 | output = `${Math.floor(difference / 31449600)} ${ 34 | Math.floor(difference / 31449600) === 1 ? "year" : "years" 35 | } ago`; 36 | } 37 | 38 | return output; 39 | } 40 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./src/**/*.{js,ts,jsx,tsx}"], 4 | darkMode: "class", 5 | theme: { 6 | extend: { 7 | colors: { 8 | gray: { 9 | 850: "#151E31", 10 | 950: "#0A0F1C", 11 | }, 12 | }, 13 | }, 14 | }, 15 | plugins: [ 16 | require("@headlessui/tailwindcss")({ prefix: "ui" }), 17 | require("@tailwindcss/typography"), 18 | ], 19 | }; 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "noUncheckedIndexedAccess": true 18 | }, 19 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.cjs", "**/*.mjs"], 20 | "exclude": ["node_modules"] 21 | } 22 | --------------------------------------------------------------------------------