├── .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 |
19 | get started
20 |
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 |
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 |
13 |
14 | MOOD
15 |
16 |
17 |
18 | {links.map((link) => (
19 |
20 | {link.name}
21 |
22 | ))}
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
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 |
47 |
48 |
52 |
Analysis
53 |
54 |
55 |
56 |
57 | Subject
58 | {currentEntry.analysis.subject}
59 |
60 |
61 |
62 | Mood
63 | {currentEntry.analysis.mood}
64 |
65 |
66 |
67 | Negative
68 |
69 | {currentEntry.analysis.negative ? 'True' : 'False'}
70 |
71 |
72 |
73 |
78 | Delete
79 |
80 |
81 |
82 |
83 |
84 |
85 | )
86 | }
87 |
88 | export default Editor
89 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [][fem]
2 |
3 | This is a companion repo for the [Build an AI Powered Fullstack App with Next.js, v3][course] course on [Frontend Masters][fem]. This application is built from scratch throughout the course. The `main` branch contains a final version of the application similar to the one built in the course. The other branches in the repo are code checkpoints which can be used as a starting point for specific lessons.
4 |
5 | ## Getting Started
6 |
7 | The [course][course] covers the full process of building and deploying the application. The steps below summarize a few of the key requirements.
8 |
9 | ### Creating the Initial Project
10 |
11 | > [!IMPORTANT]
12 | > Some dependencies used later in the course have peer dependency issues can can no longer be installed through the CLI. We recommend using the `start` branch to ensure you have the correct versions of all dependencies
13 |
14 | ```bash
15 | git clone https://github.com/Hendrixer/fullstack-ai-nextjs.git
16 | cd fullstack-ai-nextjs
17 | git checkout start
18 | npm install --legacy-peer-deps
19 | npm run dev # tests your installation an opens the basic app at http://localhost:3000
20 | ```
21 |
22 | ### Install Clerk
23 |
24 | Clerk is the third-party authentication provider for the application
25 |
26 | ```bash
27 | npm i @clerk/nextjs
28 | ```
29 |
30 | **Add Clerk secrets to .env.local**
31 |
32 | ```
33 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_XXXXXXXX
34 | CLERK_SECRET_KEY=sk_test_XXXXXX
35 | NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
36 | NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
37 | NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/journal
38 | NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/new-user
39 | ```
40 |
41 | > [!IMPORTANT]
42 | > PlanetScale has removed their free tier offering. [More info](https://planetscale.com/docs/concepts/planetscale-plans). You can still complete the course with a paid PlanetScale plan. See below for alternatives.
43 |
44 | ### PlanetScale Serverless SQL Database
45 |
46 | 1. Create a [PlanetScale Database](https://planetscale.com/)
47 | 2. Install [pscale CLI](https://github.com/planetscale/cli#installation)
48 | 3. Use the CLI to connect to the DB: `pscale auth login`
49 | 4. Create a `dev` database branch: `pscale branch create mood dev`
50 | 5. Start the connection: `pscale connect mood dev --port 3309`
51 |
52 | ### PlanetScale Alternatives
53 |
54 | There are several serverless database alternatives to PlanetScale include [Neon](https://neon.tech/docs/guides/prisma), [Turso](https://docs.turso.tech/sdk/ts/orm/prisma), [Supabase](https://supabase.com/partners/integrations/prisma), and [CockroachDB](https://www.cockroachlabs.com/docs/v23.2/build-a-nodejs-app-with-cockroachdb-prisma).
55 |
56 | Neon has a branching feature similar to PlanetScale that will be in closer alignment to this course. Follow the [Prima + Neon setup guide](https://neon.tech/docs/guides/prisma).
57 |
58 | ### Prisma ORM
59 |
60 | 1. Install Prisma Client: `npm i @prisma/client`
61 | 2. Install Prisma as dev dependency: `npm i prisma --save-dev`
62 | 3. Initialize Prisma: `npx prisma init`
63 |
64 | ### OpenAI API Account Setup
65 |
66 | 1. Create an [openai.com](https://openai.com/) account
67 | 2. Select the `API` App.
68 | 3. Create an [API Key](https://platform.openai.com/account/api-keys)
69 | 4. Copy/Paste the key into your into `.env.local` using the variable `OPENAI_API_KEY`
70 |
71 | [fem]: https://frontendmasters.com
72 | [course]: https://frontendmasters.com/courses/fullstack-app-next-v3/
73 |
--------------------------------------------------------------------------------