diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx new file mode 100644 index 0000000..ff87ae0 --- /dev/null +++ b/src/app/(auth)/layout.tsx @@ -0,0 +1,7 @@ +export default function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <>{children}; +} diff --git a/src/app/(auth)/page.tsx b/src/app/(auth)/page.tsx new file mode 100644 index 0000000..1d5a089 --- /dev/null +++ b/src/app/(auth)/page.tsx @@ -0,0 +1,23 @@ +import LoginButtons from '@src/components/LoginButtons'; +import { getServerAuthSession } from '@src/server/auth'; +import { redirect } from 'next/navigation'; + +const Login = async () => { + const session = await getServerAuthSession(); + + // If logged in, go straight to homepage + if (session?.user) { + redirect('/homepage'); + } + + return ( +
+

UTD Notebook

+

Sign in to continue

+ + +
+ ); +}; + +export default Login; diff --git a/src/app/(main)/homepage/page.tsx b/src/app/(main)/homepage/page.tsx new file mode 100644 index 0000000..a5c51ed --- /dev/null +++ b/src/app/(main)/homepage/page.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from 'next'; +import { redirect } from 'next/navigation'; +import { getServerAuthSession } from '@src/server/auth'; +export const metadata: Metadata = { + alternates: { + canonical: 'https://notebook.utdnebula.com', + }, +}; + +const Home = async () => { + const session = await getServerAuthSession(); + + // If not logged in, go to login page + if (!session?.user) { + redirect('/'); + } + return <>; +}; + +export default Home; diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx new file mode 100644 index 0000000..f211e9d --- /dev/null +++ b/src/app/(main)/layout.tsx @@ -0,0 +1,12 @@ +// src/app/(main)/layout.tsx +import type { ReactNode } from 'react'; +import NavBar from '@src/components/NavBar'; + +export default function MainLayout({ children }: { children: ReactNode }) { + return ( + <> + + {children} + + ); +} diff --git a/src/app/(main)/profile/page.tsx b/src/app/(main)/profile/page.tsx new file mode 100644 index 0000000..f0b12fb --- /dev/null +++ b/src/app/(main)/profile/page.tsx @@ -0,0 +1,112 @@ +import { getServerAuthSession } from '@src/server/auth'; + +import Avatar from '@mui/material/Avatar'; +import Link from 'next/link'; +import UploadNoteForm from '../uploadNotes/uploadNotes'; +import { db } from '@src/server/db'; +import { eq } from 'drizzle-orm'; + +export default async function ProfilePage() { + const session = await getServerAuthSession(); + + if (!session || !session.user) { + return ( +
+
+

Unauthorized

+

+ You must be logged in to view this page. +

+
+
+ ); + } + + const uploadedFiles = await db.query.file.findMany({ + where: (f) => eq(f.authorId, session.user.id), + with: { + section: true, + }, + orderBy: (f, { desc }) => [desc(f.createdAt)], + }); + + const user = { + name: session?.user?.name ?? 'John Doe', + handle: '@johndoe', + email: session?.user?.email ?? 'johndoe@example.com', + avatar: session?.user?.image ?? '/images/avatar.jpg', + posts: 0, + reports_submitted: 0, + }; + + return ( +
+
+
+ +
+

{user.name}

+
{user.handle}
+

{user.email}

+
+
+ {user.posts} posts +
+
+ {user.reports_submitted}{' '} + reports submitted +
+
+
+
+ + Edit Profile + +
+
+

Saved Notes:

+

Uploaded Notes:

+ {uploadedFiles.length === 0 ? ( +

+ You haven't uploaded any notes yet. +

+ ) : ( +
    + {uploadedFiles.map((f) => ( +
  • +
    +
    {f.fileTitle}
    +
    + {f.section + ? `${f.section.prefix} ${f.section.number}.${f.section.sectionCode}` + : 'N/A'} + • Uploaded {new Date(f.createdAt).toLocaleString()} +
    +
    + {/* This gets from local - might need to change for production release */} + + View + +
  • + ))} +
+ )} + +
+
+ ); +} diff --git a/src/app/(main)/uploadNotes/uploadNotes.tsx b/src/app/(main)/uploadNotes/uploadNotes.tsx new file mode 100644 index 0000000..d81f420 --- /dev/null +++ b/src/app/(main)/uploadNotes/uploadNotes.tsx @@ -0,0 +1,142 @@ +'use client'; + +import React, { useState } from 'react'; +import { + Box, + Button, + TextField, + MenuItem, + Typography, + Paper, +} from '@mui/material'; +import { useRouter } from 'next/navigation'; +export default function UploadNoteForm() { + const router = useRouter(); + const [file, setFile] = useState(null); + const [prefix, setPrefix] = useState(''); + const [courseNumber, setCourseNumber] = useState(''); + const [sectionCode, setSectionCode] = useState(''); + const [professor, setProfessor] = useState(''); + const [term, setTerm] = useState(''); + const [year, setYear] = useState(''); + + const termOptions = ['Spring', 'Summer', 'Fall']; + + const handleUpload = async () => { + type UploadResponse = + | { error: string } + | { message: string; data?: unknown }; + + if (!file) { + alert('Please select a file'); + return; + } + + const formData = new FormData(); + formData.append('file', file); + formData.append('prefix', prefix); + formData.append('courseNumber', courseNumber); + formData.append('sectionCode', sectionCode); + formData.append('professor', professor); + formData.append('term', term); + formData.append('year', year); + + try { + const res = await fetch('/api/files/upload', { + method: 'POST', + body: formData, + }); + + const json = (await res.json()) as UploadResponse; + + if (!res.ok && 'error' in json) { + alert(json.error || 'Upload failed'); + } else { + setFile(null); + setPrefix(''); + setCourseNumber(''); + setSectionCode(''); + setProfessor(''); + setTerm(''); + setYear(''); + alert('Upload successful'); + } + } catch (error) { + alert('Upload failed'); + console.log(error); + return; + } + router.refresh(); + }; + + return ( + + + Upload a New Note + + + + + + setPrefix(e.target.value)} + /> + + setCourseNumber(e.target.value)} + /> + + setSectionCode(e.target.value)} + /> + + setProfessor(e.target.value)} + /> + + setTerm(e.target.value)} + > + {termOptions.map((t) => ( + + {t} + + ))} + + + setYear(e.target.value)} + /> + + + + + ); +} diff --git a/src/app/AuthProvider.tsx b/src/app/AuthProvider.tsx new file mode 100644 index 0000000..7ca2ad5 --- /dev/null +++ b/src/app/AuthProvider.tsx @@ -0,0 +1,8 @@ +'use client'; + +import { SessionProvider } from 'next-auth/react'; +import type { ReactNode } from 'react'; + +export function AuthProvider({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/src/app/api/files/upload/route.ts b/src/app/api/files/upload/route.ts index 86462e0..f3fd2d2 100644 --- a/src/app/api/files/upload/route.ts +++ b/src/app/api/files/upload/route.ts @@ -15,10 +15,10 @@ const uploadFormSchema = z.object({ prefix: z.string().min(1, { message: 'Prefix is missing' }), courseNumber: z.string().min(1, { message: 'Course number is missing' }), sectionCode: z.string().min(1, { message: 'Section code is missing' }), - professor: z.string().min(1 , { message: 'Professor is missing' }), + professor: z.string().min(1, { message: 'Professor is missing' }), term: z.enum(['Spring', 'Summer', 'Fall']), - year: z.coerce.number({ message: 'Year is missing'}), -}) + year: z.coerce.number({ message: 'Year is missing' }), +}); // Upload file to database w/ file metadata (Local) export async function POST(req: Request) { @@ -42,17 +42,11 @@ export async function POST(req: Request) { const newFile = data.file as File; if (!(newFile instanceof File)) { - return NextResponse.json( - { error: 'Invalid file upload' }, - { status: 400 }, - ); + return NextResponse.json({ error: 'Invalid file upload' }, { status: 400 }); } - + if (newFile.size === 0) { - return NextResponse.json( - { error: 'File is empty' }, - { status: 400 }, - ); + return NextResponse.json({ error: 'File is empty' }, { status: 400 }); } if (!allowedTypes.includes(newFile.type)) { @@ -76,14 +70,11 @@ export async function POST(req: Request) { }); if (!sectionData) { - return NextResponse.json( - { error: 'Section not found' }, - { status: 404 }, - ); + return NextResponse.json({ error: 'Section not found' }, { status: 404 }); } try { - // Local file upload + // Local file upload - might need to change for production? const uploadDir = path.join(process.cwd(), 'public', 'uploads'); await fs.promises.mkdir(uploadDir, { recursive: true }); @@ -96,10 +87,10 @@ export async function POST(req: Request) { const fileMetadata = { authorId: session.user.id, sectionId: sectionData.id, - fileTitle: newFile.name, // required by schema - fileName: newFile.name, // required by schema + fileTitle: newFile.name, // required by schema + fileName: newFile.name, // required by schema }; - + const result = await db.insert(file).values(fileMetadata).returning(); return NextResponse.json( @@ -108,9 +99,6 @@ export async function POST(req: Request) { ); } catch (err) { console.error('File upload error:', err); - return NextResponse.json( - { error: 'File upload failed' }, - { status: 500 }, - ); + return NextResponse.json({ error: 'File upload failed' }, { status: 500 }); } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 657aae2..7303c67 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,6 +6,7 @@ import { Bai_Jamjuree, Inter } from 'next/font/google'; import { type Metadata } from 'next'; import { GoogleAnalytics } from '@next/third-parties/google'; import Link from 'next/link'; +import { AuthProvider } from './AuthProvider'; import theme from '@src/utils/theme'; import { ToastProvider } from '@src/components/toast/ToastProvider'; @@ -55,11 +56,13 @@ export default function RootLayout({ - - - {children} - - + + + + {children} + + + {process.env.NEXT_PUBLIC_VERCEL_ENV === 'production' && ( )} diff --git a/src/app/page.tsx b/src/app/page.tsx deleted file mode 100644 index 652c2f2..0000000 --- a/src/app/page.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import type { Metadata } from 'next'; -import NavBar from '@components/NavBar'; - -export const metadata: Metadata = { - alternates: { - canonical: 'https://notebook.utdnebula.com', - }, -}; - -const Home = () => { - return ( - <> - - - ); -}; - -export default Home; diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx deleted file mode 100644 index ff31ef5..0000000 --- a/src/app/profile/page.tsx +++ /dev/null @@ -1,53 +0,0 @@ -'use client'; - -import Avatar from '@mui/material/Avatar'; -import Link from 'next/link'; - -export default function ProfilePage() { - const user = { - name: 'John Doe', - handle: '@johndoe', - email: 'johndoe@example.com', - avatar: '/images/avatar.jpg', - posts: 0, - reports_submitted: 0, - }; - - return ( -
-
-
- -
-

{user.name}

-
{user.handle}
-

{user.email}

-
-
- {user.posts} posts -
-
- {user.reports_submitted}{' '} - reports submitted -
-
-
-
- - Edit Profile - -
-
-

Saved Notes:

-

Uploaded Notes:

-
-
- ); -} diff --git a/src/components/LoginButtons.tsx b/src/components/LoginButtons.tsx new file mode 100644 index 0000000..52f6d81 --- /dev/null +++ b/src/components/LoginButtons.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { signIn, signOut, useSession } from 'next-auth/react'; +import { Button, Stack, Typography } from '@mui/material'; + +export default function LoginButtons() { + const { data: session, status } = useSession(); + + if (status === 'loading') { + return Loading...; + } + + // If logged in, show user info + logout + if (session?.user) { + return ( + + + Logged in as {session.user.name ?? session.user.email} + + + + ); + } + + // If not logged in, show Google & Discord buttons + return ( + + + + + ); +} diff --git a/src/components/NavBar.tsx b/src/components/NavBar.tsx index 85b5d43..62096f1 100644 --- a/src/components/NavBar.tsx +++ b/src/components/NavBar.tsx @@ -1,7 +1,10 @@ +'use client'; + import React from 'react'; import Link from 'next/link'; import Image from 'next/image'; import { IconButton, Tooltip } from '@mui/material'; +import { signOut } from 'next-auth/react'; export default function NavBar() { return ( @@ -20,7 +23,12 @@ export default function NavBar() { > UTD Notebook - +
diff --git a/src/server/db/schema/file.ts b/src/server/db/schema/file.ts index 7364ff5..d8e75ac 100644 --- a/src/server/db/schema/file.ts +++ b/src/server/db/schema/file.ts @@ -25,25 +25,21 @@ export const file = pgTable( .notNull() .references(() => user.id, { onDelete: 'cascade' }), // Could be 'set null' too, depending on what we want to do with it. - sectionId: varchar('section_id', { length: 6 }) - .references(() => section.id, { onDelete: 'set null' }), + sectionId: varchar('section_id', { length: 6 }).references( + () => section.id, + { onDelete: 'set null' }, + ), - fileTitle: text('file_title') - .notNull(), + fileTitle: text('file_title').notNull(), - fileName: text('file_name') - .notNull(), + fileName: text('file_name').notNull(), publishDate: timestamp('publish_date', { withTimezone: true }) .notNull() .defaultNow(), - likes: integer('likes') - .notNull() - .default(0), - saves: integer('saves') - .notNull() - .default(0), + likes: integer('likes').notNull().default(0), + saves: integer('saves').notNull().default(0), // Edit flag for future workflows edited: boolean('edited').notNull().default(false), @@ -55,7 +51,7 @@ export const file = pgTable( .notNull() .defaultNow(), }, - (t) => ([ + (t) => [ // REVIEW: This is CASE SENSITIVE. File != file similar to Linux. // So a user could have "Lecture 1 Notes" and "lecture 1 notes". // I would recommend adding a PG extension for to support insensitivity. @@ -66,7 +62,7 @@ export const file = pgTable( check('file_likes_nonneg', sql`${t.likes} >= 0`), check('file_saves_nonneg', sql`${t.saves} >= 0`), - ]), + ], ); export const fileRelations = relations(file, ({ one }) => ({ @@ -78,4 +74,4 @@ export const fileRelations = relations(file, ({ one }) => ({ fields: [file.sectionId], references: [section.id], }), -})); \ No newline at end of file +})); diff --git a/src/server/db/schema/section.ts b/src/server/db/schema/section.ts index bd7244c..77dac66 100644 --- a/src/server/db/schema/section.ts +++ b/src/server/db/schema/section.ts @@ -22,27 +22,20 @@ export const section = pgTable( .primaryKey(), // "CS" or "CE", short and indexable - prefix: varchar('prefix', { length: 4 }) - .notNull(), + prefix: varchar('prefix', { length: 4 }).notNull(), // Course number like 1200 - number: varchar('number', { length: 4 }) - .notNull(), + number: varchar('number', { length: 4 }).notNull(), // Section code like "001" - sectionCode: varchar('section_code', { length: 3 }) - .notNull(), + sectionCode: varchar('section_code', { length: 3 }).notNull(), // Semester split into term + year for better filtering - term: termEnum('term') - .notNull(), - year: smallint('year') - .notNull(), + term: termEnum('term').notNull(), + year: smallint('year').notNull(), professor: text('professor'), - numberOfNotes: integer('number_of_notes') - .notNull() - .default(0), + numberOfNotes: integer('number_of_notes').notNull().default(0), // Not required, but good practice usually createdAt: timestamp('created_at', { withTimezone: true }) @@ -52,14 +45,20 @@ export const section = pgTable( .notNull() .defaultNow(), }, - (t) => ([ - uniqueIndex('section_unique_idx').on(t.prefix, t.number, t.sectionCode, t.term, t.year), + (t) => [ + uniqueIndex('section_unique_idx').on( + t.prefix, + t.number, + t.sectionCode, + t.term, + t.year, + ), index('section_by_course_idx').on(t.prefix, t.number), index('section_by_professor_idx').on(t.professor), index('section_by_semester_idx').on(t.term, t.year), - ]), + ], ); export const sectionRelations = relations(section, ({ many }) => ({ files: many(file), -})); \ No newline at end of file +}));