├── .eslintrc.json ├── .prettierrc ├── app ├── globals.css ├── favicon.ico ├── (dashboard) │ ├── journal │ │ ├── loading.tsx │ │ ├── [id] │ │ │ └── page.tsx │ │ └── page.tsx │ ├── history │ │ └── page.tsx │ └── layout.tsx ├── sign-in │ └── [[...sign-in]] │ │ └── page.tsx ├── sign-up │ └── [[...sign-signup]] │ │ └── page.tsx ├── layout.tsx ├── api │ ├── question │ │ └── route.ts │ └── entry │ │ ├── route.ts │ │ └── [id] │ │ └── route.ts ├── new-user │ └── page.tsx └── page.tsx ├── setupTests.ts ├── next.config.js ├── postcss.config.js ├── util ├── actions.ts ├── db.ts ├── auth.ts ├── api.ts └── ai.ts ├── components ├── Spinner.tsx ├── EntryCard.tsx ├── NewEntry.tsx ├── Question.tsx ├── HistoryChart.tsx └── Editor.tsx ├── tsconfig.node.json ├── middleware.ts ├── vite.config.ts ├── .gitignore ├── tailwind.config.js ├── public ├── vercel.svg └── next.svg ├── tsconfig.json ├── tests └── home.test.tsx ├── package.json ├── prisma └── schema.prisma └── README.md /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hendrixer/fullstack-ai-nextjs/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /setupTests.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import '@testing-library/jest-dom' 3 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {} 3 | 4 | module.exports = nextConfig 5 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /app/(dashboard)/journal/loading.tsx: -------------------------------------------------------------------------------- 1 | const HomeLoading = () => { 2 | return
...loading
3 | } 4 | 5 | export default HomeLoading 6 | -------------------------------------------------------------------------------- /util/actions.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { revalidatePath } from 'next/cache' 4 | 5 | export const update = (paths = []) => paths.forEach((p) => revalidatePath(p)) 6 | -------------------------------------------------------------------------------- /app/sign-in/[[...sign-in]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignIn } from '@clerk/nextjs' 2 | 3 | export default function SigninPage() { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | const Spinner = () => { 2 | return ( 3 |
4 | ) 5 | } 6 | 7 | export default Spinner 8 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { authMiddleware } from '@clerk/nextjs' 2 | 3 | export default authMiddleware({ 4 | publicRoutes: ['/'], 5 | }) 6 | 7 | export const config = { 8 | matcher: ['/((?!.*\\..*|_next).*)', '/', '/(api|trpc)(.*)'], 9 | } 10 | -------------------------------------------------------------------------------- /app/sign-up/[[...sign-signup]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignUp } from '@clerk/nextjs' 2 | 3 | export default function SignUpPage() { 4 | return ( 5 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /util/db.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client' 2 | 3 | const globalForPrisma = globalThis as unknown as { 4 | prisma: PrismaClient | undefined 5 | } 6 | 7 | export const prisma = 8 | globalForPrisma.prisma ?? 9 | new PrismaClient({ 10 | log: ['query'], 11 | }) 12 | 13 | if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma 14 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | import react from '@vitejs/plugin-react-swc' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | test: { 8 | include: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'], 9 | globals: true, 10 | environment: 'jsdom', 11 | setupFiles: 'setupTests', 12 | mockReset: true, 13 | }, 14 | }) 15 | -------------------------------------------------------------------------------- /components/EntryCard.tsx: -------------------------------------------------------------------------------- 1 | const EntryCard = ({ entry }) => { 2 | const date = new Date(entry.createdAt).toDateString() 3 | return ( 4 |
5 |
{date}
6 |
{entry.analysis.summary}
7 |
{entry.analysis.mood}
8 |
9 | ) 10 | } 11 | 12 | export default EntryCard 13 | -------------------------------------------------------------------------------- /.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 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | .env 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | './pages/**/*.{js,ts,jsx,tsx,mdx}', 5 | './components/**/*.{js,ts,jsx,tsx,mdx}', 6 | './app/**/*.{js,ts,jsx,tsx,mdx}', 7 | ], 8 | theme: { 9 | extend: { 10 | backgroundImage: { 11 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 12 | 'gradient-conic': 13 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', 14 | }, 15 | }, 16 | }, 17 | plugins: [], 18 | } 19 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import './globals.css' 2 | import { Inter } from 'next/font/google' 3 | import { ClerkProvider } from '@clerk/nextjs' 4 | 5 | const inter = Inter({ subsets: ['latin'] }) 6 | export const metadata = { 7 | title: 'Create Next App', 8 | description: 'Generated by create next app', 9 | } 10 | 11 | export default function RootLayout({ 12 | children, 13 | }: { 14 | children: React.ReactNode 15 | }) { 16 | return ( 17 | 18 | 19 | {children} 20 | 21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/api/question/route.ts: -------------------------------------------------------------------------------- 1 | import { qa } from '@/util/ai' 2 | import { getUserFromClerkID } from '@/util/auth' 3 | import { prisma } from '@/util/db' 4 | import { NextResponse } from 'next/server' 5 | 6 | export const POST = async (request) => { 7 | const { question } = await request.json() 8 | const user = await getUserFromClerkID() 9 | const entries = await prisma.journalEntry.findMany({ 10 | where: { 11 | userId: user.id, 12 | }, 13 | select: { 14 | content: true, 15 | createdAt: true, 16 | }, 17 | }) 18 | 19 | const answer = await qa(question, entries) 20 | return NextResponse.json({ data: answer }) 21 | } 22 | -------------------------------------------------------------------------------- /components/NewEntry.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { newEntry } from '@/util/api' 4 | import { revalidatePath } from 'next/cache' 5 | import { useRouter } from 'next/navigation' 6 | 7 | const NewEntry = () => { 8 | const router = useRouter() 9 | 10 | const handleOnClick = async () => { 11 | const { data } = await newEntry() 12 | router.push(`/journal/${data.id}`) 13 | } 14 | 15 | return ( 16 |
20 |
21 | New Entry 22 |
23 |
24 | ) 25 | } 26 | 27 | export default NewEntry 28 | -------------------------------------------------------------------------------- /app/new-user/page.tsx: -------------------------------------------------------------------------------- 1 | import { prisma } from '@/util/db' 2 | import { currentUser } from '@clerk/nextjs' 3 | import { redirect } from 'next/navigation' 4 | 5 | const createNewUser = async () => { 6 | const user = await currentUser() 7 | console.log(user) 8 | 9 | const match = await prisma.user.findUnique({ 10 | where: { 11 | clerkId: user.id as string, 12 | }, 13 | }) 14 | 15 | if (!match) { 16 | await prisma.user.create({ 17 | data: { 18 | clerkId: user.id, 19 | email: user?.emailAddresses[0].emailAddress, 20 | }, 21 | }) 22 | } 23 | 24 | redirect('/journal') 25 | } 26 | 27 | const NewUser = async () => { 28 | await createNewUser() 29 | return
...loading
30 | } 31 | 32 | export default NewUser 33 | -------------------------------------------------------------------------------- /app/(dashboard)/journal/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import Editor from '@/components/Editor' 2 | import { getUserFromClerkID } from '@/util/auth' 3 | import { prisma } from '@/util/db' 4 | 5 | const getEntry = async (id) => { 6 | const user = await getUserFromClerkID() 7 | const entry = await prisma.journalEntry.findUnique({ 8 | where: { 9 | userId_id: { 10 | userId: user.id, 11 | id, 12 | }, 13 | }, 14 | include: { 15 | analysis: true, 16 | }, 17 | }) 18 | 19 | return entry 20 | } 21 | 22 | const JournalEditorPage = async ({ params }) => { 23 | const entry = await getEntry(params.id) 24 | 25 | return ( 26 |
27 | 28 |
29 | ) 30 | } 31 | 32 | export default JournalEditorPage 33 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 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 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"], 28 | "references": [{ "path": "./tsconfig.node.json" }] 29 | } 30 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from '@clerk/nextjs' 2 | import Link from 'next/link' 3 | 4 | export default async function Home() { 5 | const { userId } = await auth() 6 | let href = userId ? '/journal' : '/new-user' 7 | 8 | return ( 9 |
10 |
11 |

The best Journal app, period.

12 |

13 | This is the best app for tracking your mood through out your life. All 14 | you have to do is be honest. 15 |

16 |
17 | 18 | 21 | 22 |
23 |
24 |
25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /app/api/entry/route.ts: -------------------------------------------------------------------------------- 1 | import { update } from '@/util/actions' 2 | import { getUserFromClerkID } from '@/util/auth' 3 | import { prisma } from '@/util/db' 4 | import { NextResponse } from 'next/server' 5 | 6 | export const POST = async (request: Request) => { 7 | const data = await request.json() 8 | const user = await getUserFromClerkID() 9 | const entry = await prisma.journalEntry.create({ 10 | data: { 11 | content: data.content, 12 | user: { 13 | connect: { 14 | id: user.id, 15 | }, 16 | }, 17 | analysis: { 18 | create: { 19 | mood: 'Neutral', 20 | subject: 'None', 21 | negative: false, 22 | summary: 'None', 23 | sentimentScore: 0, 24 | color: '#0101fe', 25 | userId: user.id, 26 | }, 27 | }, 28 | }, 29 | }) 30 | 31 | update(['/journal']) 32 | 33 | return NextResponse.json({ data: entry }) 34 | } 35 | -------------------------------------------------------------------------------- /tests/home.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import { vi } from 'vitest' 3 | import Page from '../app/page' 4 | 5 | vi.mock('@clerk/nextjs', () => { 6 | // Create an mockedFunctions object to match the functions we are importing from the @nextjs/clerk package in the ClerkComponent component. 7 | const mockedFunctions = { 8 | auth: () => 9 | new Promise((resolve) => 10 | resolve({ userId: 'user_2NNEqL2nrIRdJ194ndJqAHwEfxC' }) 11 | ), 12 | ClerkProvider: ({ children }) =>
{children}
, 13 | useUser: () => ({ 14 | isSignedIn: true, 15 | user: { 16 | id: 'user_2NNEqL2nrIRdJ194ndJqAHwEfxC', 17 | fullName: 'Charles Harris', 18 | }, 19 | }), 20 | } 21 | 22 | return mockedFunctions 23 | }) 24 | 25 | vi.mock('next/font/google', () => { 26 | return { 27 | Inter: () => ({ className: 'inter' }), 28 | } 29 | }) 30 | 31 | test(`Home`, async () => { 32 | render(await Page()) 33 | expect(screen.getByText('The best Journal app, period.')).toBeTruthy() 34 | }) 35 | -------------------------------------------------------------------------------- /app/(dashboard)/history/page.tsx: -------------------------------------------------------------------------------- 1 | import HistoryChart from '@/components/HistoryChart' 2 | import { getUserFromClerkID } from '@/util/auth' 3 | import { prisma } from '@/util/db' 4 | 5 | const getData = async () => { 6 | const user = await getUserFromClerkID() 7 | const analyses = await prisma.entryAnalysis.findMany({ 8 | where: { 9 | userId: user.id, 10 | }, 11 | orderBy: { 12 | createdAt: 'asc', 13 | }, 14 | }) 15 | const total = analyses.reduce((acc, curr) => { 16 | return acc + curr.sentimentScore 17 | }, 0) 18 | const average = total / analyses.length 19 | return { analyses, average } 20 | } 21 | 22 | const HistoryPage = async () => { 23 | const { analyses, average } = await getData() 24 | return ( 25 |
26 |
27 |

{`Avg. Sentiment: ${average}`}

28 |
29 |
30 | 31 |
32 |
33 | ) 34 | } 35 | 36 | export default HistoryPage 37 | -------------------------------------------------------------------------------- /util/auth.ts: -------------------------------------------------------------------------------- 1 | import type { User } from '@clerk/nextjs/api' 2 | import { prisma } from './db' 3 | import { auth } from '@clerk/nextjs' 4 | 5 | export const getUserFromClerkID = async (select = { id: true }) => { 6 | const { userId } = auth() 7 | const user = await prisma.user.findUniqueOrThrow({ 8 | where: { 9 | clerkId: userId as string, 10 | }, 11 | select, 12 | }) 13 | 14 | return user 15 | } 16 | 17 | export const syncNewUser = async (clerkUser: User) => { 18 | const existingUser = await prisma.user.findUnique({ 19 | where: { 20 | clerkId: clerkUser.id, 21 | }, 22 | }) 23 | 24 | if (!existingUser) { 25 | const email = clerkUser.emailAddresses[0].emailAddress 26 | // const { subscriptionId, customerId } = await createAndSubNewCustomer(email) 27 | 28 | await prisma.user.create({ 29 | data: { 30 | clerkId: clerkUser.id, 31 | email, 32 | account: { 33 | create: { 34 | // stripeCustomerId: customerId, 35 | // stripeSubscriptionId: subscriptionId, 36 | }, 37 | }, 38 | }, 39 | }) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fullstack-v3", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "test": "vitest" 11 | }, 12 | "dependencies": { 13 | "@clerk/nextjs": "^4.20.0", 14 | "@prisma/client": "^4.15.0", 15 | "@testing-library/jest-dom": "^5.16.5", 16 | "@testing-library/react": "^14.0.0", 17 | "@types/node": "20.3.0", 18 | "@types/react": "18.2.11", 19 | "@types/react-dom": "18.2.4", 20 | "@vitejs/plugin-react-swc": "^3.3.2", 21 | "autoprefixer": "10.4.14", 22 | "eslint": "8.42.0", 23 | "eslint-config-next": "13.4.5", 24 | "jsdom": "^22.1.0", 25 | "langchain": "^0.0.92", 26 | "next": "13.4.5", 27 | "postcss": "8.4.24", 28 | "react": "18.2.0", 29 | "react-autosave": "^0.4.2", 30 | "react-dom": "18.2.0", 31 | "recharts": "^2.6.2", 32 | "swr": "^2.1.5", 33 | "tailwindcss": "3.3.2", 34 | "typescript": "5.1.3", 35 | "victory": "^36.6.11", 36 | "vitest": "^0.32.0", 37 | "zod": "^3.21.4" 38 | }, 39 | "devDependencies": { 40 | "prisma": "^4.15.0" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /components/Question.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { askQuestion } from '@/util/api' 4 | import { useState } from 'react' 5 | 6 | const Question = () => { 7 | const [question, setQuestion] = useState('') 8 | const [answer, setAnswer] = useState(null) 9 | const [loading, setLoading] = useState(false) 10 | const handleSubmit = async (e) => { 11 | e.preventDefault() 12 | setLoading(true) 13 | 14 | const { data } = await askQuestion(question) 15 | 16 | setAnswer(data) 17 | setLoading(false) 18 | setQuestion('') 19 | } 20 | return ( 21 |
22 |
23 | setQuestion(e.target.value)} 27 | className="border border-gray-300 rounded-md p-2 text-lg" 28 | disabled={loading} 29 | placeholder="Ask a question..." 30 | /> 31 | 38 |
39 | {loading &&

Loading...

} 40 | {answer &&

{answer}

} 41 |
42 | ) 43 | } 44 | 45 | export default Question 46 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/(dashboard)/journal/page.tsx: -------------------------------------------------------------------------------- 1 | import EntryCard from '@/components/EntryCard' 2 | import NewEntry from '@/components/NewEntry' 3 | import Question from '@/components/Question' 4 | import { qa } from '@/util/ai' 5 | import { getUserFromClerkID } from '@/util/auth' 6 | import { prisma } from '@/util/db' 7 | import Link from 'next/link' 8 | 9 | const getEntries = async () => { 10 | const user = await getUserFromClerkID() 11 | const data = await prisma.journalEntry.findMany({ 12 | where: { 13 | userId: user.id, 14 | }, 15 | orderBy: { 16 | createdAt: 'desc', 17 | }, 18 | include: { 19 | analysis: true, 20 | }, 21 | }) 22 | 23 | return data 24 | } 25 | 26 | const JournalPage = async () => { 27 | const data = await getEntries() 28 | return ( 29 |
30 |

Journals

31 |
32 | 33 |
34 |
35 | 36 | {data.map((entry) => ( 37 |
38 | 39 | 40 | 41 |
42 | ))} 43 |
44 |
45 | ) 46 | } 47 | 48 | export default JournalPage 49 | -------------------------------------------------------------------------------- /app/(dashboard)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { UserButton } from '@clerk/nextjs' 2 | import Link from 'next/link' 3 | 4 | const links = [ 5 | { name: 'Journals', href: '/journal' }, 6 | { name: 'History', href: '/history' }, 7 | ] 8 | 9 | const DashboardLayout = ({ children }) => { 10 | return ( 11 |
12 | 26 |
27 |
28 | 33 |
34 |
{children}
35 |
36 |
37 | ) 38 | } 39 | 40 | export default DashboardLayout 41 | -------------------------------------------------------------------------------- /app/api/entry/[id]/route.ts: -------------------------------------------------------------------------------- 1 | import { update } from '@/util/actions' 2 | import { analyzeEntry } from '@/util/ai' 3 | import { getUserFromClerkID } from '@/util/auth' 4 | import { prisma } from '@/util/db' 5 | import { NextResponse } from 'next/server' 6 | 7 | export const DELETE = async (request: Request, { params }) => { 8 | const user = await getUserFromClerkID() 9 | 10 | await prisma.journalEntry.delete({ 11 | where: { 12 | userId_id: { 13 | id: params.id, 14 | userId: user.id, 15 | }, 16 | }, 17 | }) 18 | 19 | update(['/journal']) 20 | 21 | return NextResponse.json({ data: { id: params.id } }) 22 | } 23 | 24 | export const PATCH = async (request: Request, { params }) => { 25 | const { updates } = await request.json() 26 | const user = await getUserFromClerkID() 27 | 28 | const entry = await prisma.journalEntry.update({ 29 | where: { 30 | userId_id: { 31 | id: params.id, 32 | userId: user.id, 33 | }, 34 | }, 35 | data: updates, 36 | }) 37 | 38 | const analysis = await analyzeEntry(entry) 39 | const savedAnalysis = await prisma.entryAnalysis.upsert({ 40 | where: { 41 | entryId: entry.id, 42 | }, 43 | update: { ...analysis }, 44 | create: { 45 | entryId: entry.id, 46 | userId: user.id, 47 | ...analysis, 48 | }, 49 | }) 50 | 51 | update(['/journal']) 52 | 53 | return NextResponse.json({ data: { ...entry, analysis: savedAnalysis } }) 54 | } 55 | -------------------------------------------------------------------------------- /components/HistoryChart.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { ResponsiveContainer, LineChart, Line, XAxis, Tooltip } from 'recharts' 3 | 4 | const CustomTooltip = ({ payload, label, active }) => { 5 | const dateLabel = new Date(label).toLocaleString('en-us', { 6 | weekday: 'long', 7 | year: 'numeric', 8 | month: 'short', 9 | day: 'numeric', 10 | hour: 'numeric', 11 | minute: 'numeric', 12 | }) 13 | 14 | if (active) { 15 | const analysis = payload[0].payload 16 | return ( 17 |
18 |
22 |

{dateLabel}

23 |

{analysis.mood}

24 |
25 | ) 26 | } 27 | 28 | return null 29 | } 30 | 31 | const HistoryChart = ({ data }) => { 32 | return ( 33 | 34 | 35 | 42 | 43 | } /> 44 | 45 | 46 | ) 47 | } 48 | 49 | export default HistoryChart 50 | -------------------------------------------------------------------------------- /util/api.ts: -------------------------------------------------------------------------------- 1 | const createURL = (path) => window.location.origin + path 2 | 3 | export const fetcher = (...args) => fetch(...args).then((res) => res.json()) 4 | 5 | export const deleteEntry = async (id) => { 6 | const res = await fetch( 7 | new Request(createURL(`/api/entry/${id}`), { 8 | method: 'DELETE', 9 | }) 10 | ) 11 | 12 | if (res.ok) { 13 | return res.json() 14 | } else { 15 | throw new Error('Something went wrong on API server!') 16 | } 17 | } 18 | 19 | export const newEntry = async () => { 20 | const res = await fetch( 21 | new Request(createURL('/api/entry'), { 22 | method: 'POST', 23 | body: JSON.stringify({ content: 'new entry' }), 24 | }) 25 | ) 26 | 27 | if (res.ok) { 28 | return res.json() 29 | } else { 30 | throw new Error('Something went wrong on API server!') 31 | } 32 | } 33 | 34 | export const updateEntry = async (id, updates) => { 35 | const res = await fetch( 36 | new Request(createURL(`/api/entry/${id}`), { 37 | method: 'PATCH', 38 | body: JSON.stringify({ updates }), 39 | }) 40 | ) 41 | 42 | if (res.ok) { 43 | return res.json() 44 | } else { 45 | throw new Error('Something went wrong on API server!') 46 | } 47 | } 48 | 49 | export const askQuestion = async (question) => { 50 | const res = await fetch( 51 | new Request(createURL(`/api/question`), { 52 | method: 'POST', 53 | body: JSON.stringify({ question }), 54 | }) 55 | ) 56 | 57 | if (res.ok) { 58 | return res.json() 59 | } else { 60 | throw new Error('Something went wrong on API server!') 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "mysql" 7 | url = env("DATABASE_URL") 8 | relationMode = "prisma" 9 | } 10 | 11 | model User { 12 | id String @id @default(uuid()) 13 | email String @unique 14 | clerkId String @unique 15 | name String? 16 | createdAt DateTime @default(now()) 17 | updatedAt DateTime @updatedAt 18 | account Account? 19 | entries JournalEntry[] 20 | analysis EntryAnalysis[] 21 | } 22 | 23 | model Account { 24 | id String @id @default(uuid()) 25 | userId String 26 | user User @relation(fields: [userId], references: [id]) 27 | // stripeCustomerId String @unique 28 | 29 | @@unique([userId]) 30 | } 31 | 32 | enum JOURNAL_ENTRY_STATUS { 33 | DRAFT 34 | PUBLISHED 35 | ARCHIVED 36 | } 37 | 38 | model JournalEntry { 39 | id String @id @default(uuid()) 40 | userId String 41 | user User @relation(fields: [userId], references: [id]) 42 | createdAt DateTime @default(now()) 43 | updatedAt DateTime @updatedAt 44 | 45 | content String @db.Text 46 | status JOURNAL_ENTRY_STATUS @default(DRAFT) 47 | analysis EntryAnalysis? 48 | 49 | @@unique([userId, id]) 50 | } 51 | 52 | model EntryAnalysis { 53 | id String @id @default(uuid()) 54 | createdAt DateTime @default(now()) 55 | updatedAt DateTime @updatedAt 56 | 57 | entryId String 58 | entry JournalEntry @relation(fields: [entryId], references: [id], onDelete: Cascade) 59 | 60 | userId String 61 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 62 | 63 | mood String @db.Text 64 | subject String @db.Text 65 | negative Boolean 66 | summary String @db.Text 67 | color String @db.Text @default("#0101fe") 68 | sentimentScore Float 69 | 70 | @@unique([entryId]) 71 | @@index([userId]) 72 | } 73 | -------------------------------------------------------------------------------- /util/ai.ts: -------------------------------------------------------------------------------- 1 | import { OpenAI } from 'langchain/llms/openai' 2 | import { PromptTemplate } from 'langchain/prompts' 3 | import { loadQARefineChain } from 'langchain/chains' 4 | import { MemoryVectorStore } from 'langchain/vectorstores/memory' 5 | import { OpenAIEmbeddings } from 'langchain/embeddings/openai' 6 | 7 | import { 8 | StructuredOutputParser, 9 | OutputFixingParser, 10 | } from 'langchain/output_parsers' 11 | import { Document } from 'langchain/document' 12 | import { z } from 'zod' 13 | 14 | const parser = StructuredOutputParser.fromZodSchema( 15 | z.object({ 16 | mood: z 17 | .string() 18 | .describe('the mood of the person who wrote the journal entry.'), 19 | subject: z.string().describe('the subject of the journal entry.'), 20 | negative: z 21 | .boolean() 22 | .describe( 23 | 'is the journal entry negative? (i.e. does it contain negative emotions?).' 24 | ), 25 | summary: z.string().describe('quick summary of the entire entry.'), 26 | color: z 27 | .string() 28 | .describe( 29 | 'a hexidecimal color code that represents the mood of the entry. Example #0101fe for blue representing happiness.' 30 | ), 31 | sentimentScore: z 32 | .number() 33 | .describe( 34 | 'sentiment of the text and rated on a scale from -10 to 10, where -10 is extremely negative, 0 is neutral, and 10 is extremely positive.' 35 | ), 36 | }) 37 | ) 38 | 39 | const getPrompt = async (content) => { 40 | const format_instructions = parser.getFormatInstructions() 41 | 42 | const prompt = new PromptTemplate({ 43 | template: 44 | 'Analyze the following journal entry. Follow the intrusctions and format your response to match the format instructions, no matter what! \n{format_instructions}\n{entry}', 45 | inputVariables: ['entry'], 46 | partialVariables: { format_instructions }, 47 | }) 48 | 49 | const input = await prompt.format({ 50 | entry: content, 51 | }) 52 | 53 | return input 54 | } 55 | 56 | export const analyzeEntry = async (entry) => { 57 | const input = await getPrompt(entry.content) 58 | const model = new OpenAI({ temperature: 0, modelName: 'gpt-3.5-turbo' }) 59 | const output = await model.call(input) 60 | 61 | try { 62 | return parser.parse(output) 63 | } catch (e) { 64 | const fixParser = OutputFixingParser.fromLLM( 65 | new OpenAI({ temperature: 0, modelName: 'gpt-3.5-turbo' }), 66 | parser 67 | ) 68 | const fix = await fixParser.parse(output) 69 | return fix 70 | } 71 | } 72 | 73 | export const qa = async (question, entries) => { 74 | const docs = entries.map( 75 | (entry) => 76 | new Document({ 77 | pageContent: entry.content, 78 | metadata: { source: entry.id, date: entry.createdAt }, 79 | }) 80 | ) 81 | const model = new OpenAI({ temperature: 0, modelName: 'gpt-3.5-turbo' }) 82 | const chain = loadQARefineChain(model) 83 | const embeddings = new OpenAIEmbeddings() 84 | const store = await MemoryVectorStore.fromDocuments(docs, embeddings) 85 | const relevantDocs = await store.similaritySearch(question) 86 | const res = await chain.call({ 87 | input_documents: relevantDocs, 88 | question, 89 | }) 90 | 91 | return res.output_text 92 | } 93 | -------------------------------------------------------------------------------- /components/Editor.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { updateEntry, deleteEntry } from '@/util/api' 3 | import { useState } from 'react' 4 | import { useAutosave } from 'react-autosave' 5 | import Spinner from './Spinner' 6 | import { useRouter } from 'next/navigation' 7 | 8 | const Editor = ({ entry }) => { 9 | const [text, setText] = useState(entry.content) 10 | const [currentEntry, setEntry] = useState(entry) 11 | const [isSaving, setIsSaving] = useState(false) 12 | const router = useRouter() 13 | 14 | const handleDelete = async () => { 15 | await deleteEntry(entry.id) 16 | router.push('/journal') 17 | } 18 | useAutosave({ 19 | data: text, 20 | onSave: async (_text) => { 21 | if (_text === entry.content) return 22 | setIsSaving(true) 23 | 24 | const { data } = await updateEntry(entry.id, { content: _text }) 25 | 26 | setEntry(data) 27 | setIsSaving(false) 28 | }, 29 | }) 30 | 31 | return ( 32 |
33 |
34 | {isSaving ? ( 35 | 36 | ) : ( 37 |
38 | )} 39 |
40 |
41 |