From 30798c3b76a2534dfd9cc349b0ebe6ff18db0ebb Mon Sep 17 00:00:00 2001 From: "laochou.sc" Date: Tue, 31 Mar 2026 10:48:25 +0800 Subject: [PATCH 1/2] add vercel oceanbase demo --- oceanbase-nextjs-vercel-demo/.env.example | 7 + oceanbase-nextjs-vercel-demo/.eslintrc.json | 4 + oceanbase-nextjs-vercel-demo/.gitignore | 53 + oceanbase-nextjs-vercel-demo/README.md | 57 + .../app/api/courses/[id]/capacity/route.ts | 43 + .../app/api/courses/[id]/reviews/route.ts | 70 + .../app/api/courses/[id]/route.ts | 47 + .../app/api/courses/route.ts | 76 + .../app/api/reviews/[id]/route.ts | 19 + oceanbase-nextjs-vercel-demo/app/globals.css | 390 ++ oceanbase-nextjs-vercel-demo/app/layout.tsx | 20 + oceanbase-nextjs-vercel-demo/app/page.tsx | 630 ++ oceanbase-nextjs-vercel-demo/next.config.js | 7 + .../package-lock.json | 5316 +++++++++++++++++ oceanbase-nextjs-vercel-demo/package.json | 29 + oceanbase-nextjs-vercel-demo/tsconfig.json | 28 + oceanbase-nextjs-vercel-demo/vercel.json | 4 + 17 files changed, 6800 insertions(+) create mode 100644 oceanbase-nextjs-vercel-demo/.env.example create mode 100644 oceanbase-nextjs-vercel-demo/.eslintrc.json create mode 100644 oceanbase-nextjs-vercel-demo/.gitignore create mode 100644 oceanbase-nextjs-vercel-demo/README.md create mode 100644 oceanbase-nextjs-vercel-demo/app/api/courses/[id]/capacity/route.ts create mode 100644 oceanbase-nextjs-vercel-demo/app/api/courses/[id]/reviews/route.ts create mode 100644 oceanbase-nextjs-vercel-demo/app/api/courses/[id]/route.ts create mode 100644 oceanbase-nextjs-vercel-demo/app/api/courses/route.ts create mode 100644 oceanbase-nextjs-vercel-demo/app/api/reviews/[id]/route.ts create mode 100644 oceanbase-nextjs-vercel-demo/app/globals.css create mode 100644 oceanbase-nextjs-vercel-demo/app/layout.tsx create mode 100644 oceanbase-nextjs-vercel-demo/app/page.tsx create mode 100644 oceanbase-nextjs-vercel-demo/next.config.js create mode 100644 oceanbase-nextjs-vercel-demo/package-lock.json create mode 100644 oceanbase-nextjs-vercel-demo/package.json create mode 100644 oceanbase-nextjs-vercel-demo/tsconfig.json create mode 100644 oceanbase-nextjs-vercel-demo/vercel.json diff --git a/oceanbase-nextjs-vercel-demo/.env.example b/oceanbase-nextjs-vercel-demo/.env.example new file mode 100644 index 00000000..dd478fc0 --- /dev/null +++ b/oceanbase-nextjs-vercel-demo/.env.example @@ -0,0 +1,7 @@ +# OceanBase Cloud database connection +# Get values from: OceanBase Cloud console โ†’ your cluster โ†’ connection info +# Format: mysql://username:password@host:port/database + +DATABASE_URL=mysql://:@:/ + +# Copy this file to .env and fill in your values for local development or Vercel diff --git a/oceanbase-nextjs-vercel-demo/.eslintrc.json b/oceanbase-nextjs-vercel-demo/.eslintrc.json new file mode 100644 index 00000000..f18272b8 --- /dev/null +++ b/oceanbase-nextjs-vercel-demo/.eslintrc.json @@ -0,0 +1,4 @@ +{ + "extends": "next/core-web-vitals" +} + diff --git a/oceanbase-nextjs-vercel-demo/.gitignore b/oceanbase-nextjs-vercel-demo/.gitignore new file mode 100644 index 00000000..9975cf95 --- /dev/null +++ b/oceanbase-nextjs-vercel-demo/.gitignore @@ -0,0 +1,53 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +yarn.lock + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem +*.log + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# local env files +.env*.local +.env +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +Thumbs.db + diff --git a/oceanbase-nextjs-vercel-demo/README.md b/oceanbase-nextjs-vercel-demo/README.md new file mode 100644 index 00000000..d6a4d2fc --- /dev/null +++ b/oceanbase-nextjs-vercel-demo/README.md @@ -0,0 +1,57 @@ +# UniSelect Course Demo + +**UniSelect Course** is a university course selection management system. Use it to manage course information, view course details, add and delete reviews, and edit course capacity. + +Built with **Next.js**, **OceanBase Cloud** , and deployable on **Vercel**. + +## Demo + +![UniSelect Course screenshot](https://github.com/user-attachments/assets/d1fa46a9-cbe7-4b62-9015-f053da41ac5b) + +**Live demo:** [Open the app](https://oceanbase-nextjs-vercel-demo.vercel.app) + +## Deploy to Vercel + +You can deploy this demo with one click if you already have an OceanBase database (or create one after provisioning through [OceanBase Cloud](https://www.oceanbase.com/product/cloud)). + +**Quick deployment**: + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/sc-source/oceanbase-nextjs-vercel-demo&project-name=OceanBase%20Cloud%20Starter&repository-name=oceanbase-cloud-starter&integration-ids=oac_kzJzQ0seDkU8FXrt6cgoec48&demo-title=OceanBase%20Cloud%20Starter&demo-description=A%20university%20course%20selection%20management%20system%20built%20with%20Next.js%20and%20OceanBase&demo-url=https%3A%2F%2Foceanbase-nextjs-vercel-demo.vercel.app%2F&demo-image=https%3A%2F%2Fgithub.com%2Fuser-attachments%2Fassets%2Fd1fa46a9-cbe7-4b62-9015-f053da41ac5b) + +## Local setup + +### Install dependencies + +```bash +npm install +``` + +### Environment + +Copy the example env file and fill in your OceanBase connection string (from the [OceanBase Cloud console](https://www.oceanbase.com/product/cloud) โ†’ your cluster โ†’ connection info): + +```bash +cp .env.example .env +``` + +```env +DATABASE_URL=mysql://:@:/ +``` + +### Run the dev server + +```bash +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000) in your browser. + +## Learn more + +- [OceanBase documentation](https://www.oceanbase.com/docs) +- [Vercel documentation](https://vercel.com/docs) +- [Next.js documentation](https://nextjs.org/docs) + +## License + +MIT diff --git a/oceanbase-nextjs-vercel-demo/app/api/courses/[id]/capacity/route.ts b/oceanbase-nextjs-vercel-demo/app/api/courses/[id]/capacity/route.ts new file mode 100644 index 00000000..0fdbecc8 --- /dev/null +++ b/oceanbase-nextjs-vercel-demo/app/api/courses/[id]/capacity/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { query } from '@/lib/db'; + +// PUT - update course capacity +export async function PUT( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const body = await request.json(); + const { capacity } = body; + + if (!capacity || capacity < 0) { + return NextResponse.json( + { success: false, error: 'Valid capacity is required' }, + { status: 400 } + ); + } + + await query( + 'UPDATE `courses` SET `capacity` = ? WHERE `id` = ?', + [capacity, params.id] + ); + + const courses = await query( + 'SELECT `id`, `code`, `name`, `capacity`, `enrolled` FROM `courses` WHERE `id` = ?', + [params.id] + ); + + const courseArray = courses as any[]; + return NextResponse.json({ + success: true, + data: courseArray[0], + message: 'Course capacity updated successfully', + }); + } catch (error: any) { + return NextResponse.json( + { success: false, error: error.message }, + { status: 500 } + ); + } +} + diff --git a/oceanbase-nextjs-vercel-demo/app/api/courses/[id]/reviews/route.ts b/oceanbase-nextjs-vercel-demo/app/api/courses/[id]/reviews/route.ts new file mode 100644 index 00000000..0da13bc0 --- /dev/null +++ b/oceanbase-nextjs-vercel-demo/app/api/courses/[id]/reviews/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { query } from '@/lib/db'; + +// GET - list reviews for course +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const reviews = await query( + 'SELECT `id`, `course_id`, `student_name`, `rating`, `comment`, `created_at` FROM `reviews` WHERE `course_id` = ? ORDER BY `created_at` DESC', + [params.id] + ); + + return NextResponse.json({ success: true, data: reviews }); + } catch (error: any) { + return NextResponse.json( + { success: false, error: error.message }, + { status: 500 } + ); + } +} + +// POST - add review +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const body = await request.json(); + const { student_name, rating, comment } = body; + + if (!student_name || !rating) { + return NextResponse.json( + { success: false, error: 'Student name and rating are required' }, + { status: 400 } + ); + } + + if (rating < 1 || rating > 5) { + return NextResponse.json( + { success: false, error: 'Rating must be between 1 and 5' }, + { status: 400 } + ); + } + + const result = await query( + 'INSERT INTO `reviews` (`course_id`, `student_name`, `rating`, `comment`) VALUES (?, ?, ?, ?)', + [params.id, student_name, rating, comment || null] + ); + + const insertResult = result as any; + return NextResponse.json({ + success: true, + data: { + id: insertResult.insertId, + course_id: parseInt(params.id), + student_name, + rating, + comment, + }, + }); + } catch (error: any) { + return NextResponse.json( + { success: false, error: error.message }, + { status: 500 } + ); + } +} + diff --git a/oceanbase-nextjs-vercel-demo/app/api/courses/[id]/route.ts b/oceanbase-nextjs-vercel-demo/app/api/courses/[id]/route.ts new file mode 100644 index 00000000..ef0edea4 --- /dev/null +++ b/oceanbase-nextjs-vercel-demo/app/api/courses/[id]/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { query } from '@/lib/db'; + +// GET - course by id +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const courses = await query( + 'SELECT `id`, `code`, `name`, `description`, `instructor`, `department`, `credits`, `capacity`, `enrolled`, `semester` FROM `courses` WHERE `id` = ?', + [params.id] + ); + + const courseArray = courses as any[]; + if (courseArray.length === 0) { + return NextResponse.json( + { success: false, error: 'Course not found' }, + { status: 404 } + ); + } + + return NextResponse.json({ success: true, data: courseArray[0] }); + } catch (error: any) { + return NextResponse.json( + { success: false, error: error.message }, + { status: 500 } + ); + } +} + +// DELETE - remove course +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + await query('DELETE FROM `courses` WHERE `id` = ?', [params.id]); + return NextResponse.json({ success: true, message: 'Course deleted successfully' }); + } catch (error: any) { + return NextResponse.json( + { success: false, error: error.message }, + { status: 500 } + ); + } +} + diff --git a/oceanbase-nextjs-vercel-demo/app/api/courses/route.ts b/oceanbase-nextjs-vercel-demo/app/api/courses/route.ts new file mode 100644 index 00000000..ceda21bc --- /dev/null +++ b/oceanbase-nextjs-vercel-demo/app/api/courses/route.ts @@ -0,0 +1,76 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { query } from '@/lib/db'; + +// GET - list courses +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const department = searchParams.get('department'); + const semester = searchParams.get('semester'); + + let sql = 'SELECT `id`, `code`, `name`, `description`, `instructor`, `department`, `credits`, `capacity`, `enrolled`, `semester` FROM `courses`'; + const params: any[] = []; + + if (department || semester) { + const conditions: string[] = []; + if (department) { + conditions.push('`department` = ?'); + params.push(department); + } + if (semester) { + conditions.push('`semester` = ?'); + params.push(semester); + } + sql += ' WHERE ' + conditions.join(' AND '); + } + + sql += ' ORDER BY `code` ASC'; + + const courses = await query(sql, params); + return NextResponse.json({ success: true, data: courses }); + } catch (error: any) { + return NextResponse.json( + { success: false, error: error.message }, + { status: 500 } + ); + } +} + +// POST - create course +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { code, name, description, instructor, department, credits, capacity, semester } = body; + + if (!code || !name || !instructor || !department) { + return NextResponse.json( + { success: false, error: 'Code, name, instructor, and department are required' }, + { status: 400 } + ); + } + + const result = await query( + `INSERT INTO \`courses\` (\`code\`, \`name\`, \`description\`, \`instructor\`, \`department\`, \`credits\`, \`capacity\`, \`semester\`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [code, name, description || null, instructor, department, credits || 3, capacity || 30, semester || 'Fall 2024'] + ); + + const insertResult = result as any; + return NextResponse.json({ + success: true, + data: { id: insertResult.insertId, code, name, instructor, department }, + }); + } catch (error: any) { + if (error.code === 'ER_DUP_ENTRY') { + return NextResponse.json( + { success: false, error: 'Course code already exists' }, + { status: 400 } + ); + } + return NextResponse.json( + { success: false, error: error.message }, + { status: 500 } + ); + } +} + diff --git a/oceanbase-nextjs-vercel-demo/app/api/reviews/[id]/route.ts b/oceanbase-nextjs-vercel-demo/app/api/reviews/[id]/route.ts new file mode 100644 index 00000000..f5f83256 --- /dev/null +++ b/oceanbase-nextjs-vercel-demo/app/api/reviews/[id]/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { query } from '@/lib/db'; + +// DELETE - remove a review +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + await query('DELETE FROM `reviews` WHERE `id` = ?', [params.id]); + return NextResponse.json({ success: true, message: 'Review deleted successfully' }); + } catch (error: any) { + return NextResponse.json( + { success: false, error: error.message }, + { status: 500 } + ); + } +} + diff --git a/oceanbase-nextjs-vercel-demo/app/globals.css b/oceanbase-nextjs-vercel-demo/app/globals.css new file mode 100644 index 00000000..33ec08e8 --- /dev/null +++ b/oceanbase-nextjs-vercel-demo/app/globals.css @@ -0,0 +1,390 @@ +* { + box-sizing: border-box; + padding: 0; + margin: 0; +} + +html, +body { + max-width: 100vw; + overflow-x: hidden; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; + padding: 2rem; +} + +a { + color: inherit; + text-decoration: none; +} + +.container { + max-width: 1400px; + margin: 0 auto; + background: white; + border-radius: 12px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + padding: 2rem; +} + +.header { + text-align: center; + margin-bottom: 3rem; + padding-bottom: 2rem; + border-bottom: 2px solid #f0f0f0; +} + +.header h1 { + color: #333; + font-size: 2.5rem; + margin-bottom: 0.5rem; +} + +.header p { + color: #666; + font-size: 1.1rem; +} + +.error { + background: #fee; + color: #c33; + padding: 1rem; + border-radius: 6px; + margin-bottom: 1rem; + border-left: 4px solid #c33; +} + +.success { + background: #efe; + color: #3c3; + padding: 1rem; + border-radius: 6px; + margin-bottom: 1rem; + border-left: 4px solid #3c3; +} + +.loading { + text-align: center; + padding: 2rem; + color: #666; +} + +.empty { + text-align: center; + padding: 2rem; + color: #999; +} + +.form-section { + background: #f8f9fa; + padding: 1.5rem; + border-radius: 8px; + margin-bottom: 2rem; +} + +.form-section h2 { + margin-bottom: 1rem; + color: #333; +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + color: #333; + font-weight: 500; +} + +.form-group input, +.form-group textarea, +.form-group select { + width: 100%; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; + font-family: inherit; +} + +.form-group textarea { + min-height: 100px; + resize: vertical; +} + +.button { + background: #667eea; + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: 6px; + font-size: 1rem; + cursor: pointer; + transition: background 0.2s; + font-weight: 500; +} + +.button:hover { + background: #5568d3; +} + +.button:active { + transform: scale(0.98); +} + +.button-secondary { + background: #6c757d; +} + +.button-secondary:hover { + background: #5a6268; +} + +.button-danger { + background: #dc3545; +} + +.button-danger:hover { + background: #c82333; +} + +.button-small { + padding: 0.5rem 1rem; + font-size: 0.9rem; +} + +.table-section { + margin-top: 2rem; +} + +.table-section h2 { + margin-bottom: 1rem; + color: #333; +} + +.course-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + gap: 1.5rem; + margin-top: 1.5rem; +} + +.course-card { + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 1.5rem; + background: white; + transition: transform 0.2s, box-shadow 0.2s; + cursor: pointer; +} + +.course-card:hover { + transform: translateY(-4px); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1); +} + +.course-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; +} + +.course-code { + font-size: 0.9rem; + color: #667eea; + font-weight: 600; + background: #f0f4ff; + padding: 0.25rem 0.75rem; + border-radius: 4px; +} + +.course-name { + font-size: 1.25rem; + font-weight: 600; + color: #333; + margin: 0.5rem 0; +} + +.course-info { + color: #666; + font-size: 0.9rem; + margin: 0.25rem 0; +} + +.course-meta { + display: flex; + justify-content: space-between; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid #f0f0f0; +} + +.capacity-info { + font-size: 0.85rem; + color: #666; +} + +.capacity-info strong { + color: #333; +} + +.actions { + display: flex; + gap: 0.5rem; + margin-top: 1rem; +} + +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 2rem; +} + +.modal { + background: white; + border-radius: 12px; + padding: 2rem; + max-width: 800px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + position: relative; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 2px solid #f0f0f0; +} + +.modal-header h2 { + color: #333; + margin: 0; +} + +.close-button { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: #999; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; +} + +.close-button:hover { + background: #f0f0f0; + color: #333; +} + +.reviews-section { + margin-top: 2rem; +} + +.review-item { + background: #f8f9fa; + padding: 1rem; + border-radius: 6px; + margin-bottom: 1rem; + border-left: 4px solid #667eea; +} + +.review-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.review-author { + font-weight: 600; + color: #333; +} + +.review-rating { + color: #ffc107; + font-size: 1.1rem; +} + +.review-comment { + color: #666; + margin-top: 0.5rem; + line-height: 1.6; +} + +.review-date { + font-size: 0.85rem; + color: #999; + margin-top: 0.5rem; +} + +.rating-input { + display: flex; + gap: 0.5rem; + align-items: center; + margin: 1rem 0; +} + +.rating-stars { + display: flex; + gap: 0.25rem; + font-size: 1.5rem; + cursor: pointer; +} + +.star { + color: #ddd; + transition: color 0.2s; +} + +.star.active { + color: #ffc107; +} + +.filters { + display: flex; + gap: 1rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +.filter-group { + flex: 1; + min-width: 200px; +} + +.filter-group label { + display: block; + margin-bottom: 0.5rem; + color: #333; + font-weight: 500; + font-size: 0.9rem; +} + diff --git a/oceanbase-nextjs-vercel-demo/app/layout.tsx b/oceanbase-nextjs-vercel-demo/app/layout.tsx new file mode 100644 index 00000000..3f2defc3 --- /dev/null +++ b/oceanbase-nextjs-vercel-demo/app/layout.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from 'next'; +import './globals.css'; + +export const metadata: Metadata = { + title: 'UniSelect Course', + description: + 'UniSelect Course is a university course selection management system. Manage courses, view details, add and delete reviews, and edit capacity.', +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/oceanbase-nextjs-vercel-demo/app/page.tsx b/oceanbase-nextjs-vercel-demo/app/page.tsx new file mode 100644 index 00000000..5f67d153 --- /dev/null +++ b/oceanbase-nextjs-vercel-demo/app/page.tsx @@ -0,0 +1,630 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +interface Course { + id: number; + code: string; + name: string; + description?: string; + instructor: string; + department: string; + credits: number; + capacity: number; + enrolled: number; + semester: string; +} + +interface Review { + id: number; + course_id: number; + student_name: string; + rating: number; + comment?: string; + created_at: string; +} + +export default function Home() { + const [courses, setCourses] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [selectedCourse, setSelectedCourse] = useState(null); + const [reviews, setReviews] = useState([]); + const [showModal, setShowModal] = useState(false); + const [showCreateModal, setShowCreateModal] = useState(false); + const [showReviewForm, setShowReviewForm] = useState(false); + const [departmentFilter, setDepartmentFilter] = useState(''); + const [semesterFilter, setSemesterFilter] = useState(''); + + const [formData, setFormData] = useState({ + code: '', + name: '', + description: '', + instructor: '', + department: '', + credits: 3, + capacity: 30, + semester: 'Fall 2024', + }); + + const [reviewForm, setReviewForm] = useState({ + student_name: '', + rating: 0, + comment: '', + }); + + const [capacityForm, setCapacityForm] = useState({ capacity: 30 }); + + const fetchCourses = async () => { + try { + setLoading(true); + setError(null); + const params = new URLSearchParams(); + if (departmentFilter) params.append('department', departmentFilter); + if (semesterFilter) params.append('semester', semesterFilter); + + const response = await fetch(`/api/courses?${params.toString()}`); + const result = await response.json(); + + if (result.success) { + setCourses(result.data); + } else { + setError(result.error || 'Failed to fetch courses'); + } + } catch (err: any) { + setError(err.message || 'Failed to fetch courses'); + } finally { + setLoading(false); + } + }; + + const fetchCourseDetails = async (courseId: number) => { + try { + const response = await fetch(`/api/courses/${courseId}`); + const result = await response.json(); + + if (result.success) { + setSelectedCourse(result.data); + fetchReviews(courseId); + setShowModal(true); + } + } catch (err: any) { + setError(err.message || 'Failed to fetch course details'); + } + }; + + const fetchReviews = async (courseId: number) => { + try { + const response = await fetch(`/api/courses/${courseId}/reviews`); + const result = await response.json(); + + if (result.success) { + setReviews(result.data); + } + } catch (err: any) { + console.error('Failed to fetch reviews:', err); + } + }; + + const createCourse = async (e: React.FormEvent) => { + e.preventDefault(); + try { + setError(null); + setSuccess(null); + + const response = await fetch('/api/courses', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(formData), + }); + + const result = await response.json(); + + if (result.success) { + setSuccess('Course created successfully!'); + setFormData({ + code: '', + name: '', + description: '', + instructor: '', + department: '', + credits: 3, + capacity: 30, + semester: 'Fall 2024', + }); + setShowCreateModal(false); + fetchCourses(); + } else { + setError(result.error || 'Failed to create course'); + } + } catch (err: any) { + setError(err.message || 'Failed to create course'); + } + }; + + const updateCapacity = async (courseId: number) => { + try { + setError(null); + setSuccess(null); + + const response = await fetch(`/api/courses/${courseId}/capacity`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(capacityForm), + }); + + const result = await response.json(); + + if (result.success) { + setSuccess('Course capacity updated successfully!'); + if (selectedCourse) { + setSelectedCourse({ ...selectedCourse, capacity: capacityForm.capacity }); + } + fetchCourses(); + } else { + setError(result.error || 'Failed to update capacity'); + } + } catch (err: any) { + setError(err.message || 'Failed to update capacity'); + } + }; + + const addReview = async (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedCourse || reviewForm.rating === 0) { + setError('Please provide a rating'); + return; + } + + try { + setError(null); + setSuccess(null); + + const response = await fetch(`/api/courses/${selectedCourse.id}/reviews`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(reviewForm), + }); + + const result = await response.json(); + + if (result.success) { + setSuccess('Review added successfully!'); + setReviewForm({ student_name: '', rating: 0, comment: '' }); + setShowReviewForm(false); + fetchReviews(selectedCourse.id); + } else { + setError(result.error || 'Failed to add review'); + } + } catch (err: any) { + setError(err.message || 'Failed to add review'); + } + }; + + const deleteReview = async (reviewId: number) => { + if (!confirm('Are you sure you want to delete this review?')) return; + + try { + setError(null); + setSuccess(null); + + const response = await fetch(`/api/reviews/${reviewId}`, { + method: 'DELETE', + }); + + const result = await response.json(); + + if (result.success) { + setSuccess('Review deleted successfully!'); + if (selectedCourse) { + fetchReviews(selectedCourse.id); + } + } else { + setError(result.error || 'Failed to delete review'); + } + } catch (err: any) { + setError(err.message || 'Failed to delete review'); + } + }; + + const deleteCourse = async (courseId: number) => { + if (!confirm('Are you sure you want to delete this course? This will also delete all reviews.')) return; + + try { + setError(null); + setSuccess(null); + + const response = await fetch(`/api/courses/${courseId}`, { + method: 'DELETE', + }); + + const result = await response.json(); + + if (result.success) { + setSuccess('Course deleted successfully!'); + setShowModal(false); + setSelectedCourse(null); + fetchCourses(); + } else { + setError(result.error || 'Failed to delete course'); + } + } catch (err: any) { + setError(err.message || 'Failed to delete course'); + } + }; + + const departments = Array.from(new Set(courses.map(c => c.department))); + + useEffect(() => { + fetchCourses(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [departmentFilter, semesterFilter]); + + return ( +
+
+

๐ŸŽ“ UniSelect Course

+

+ University course selection admin ยท Manage courses, view details, add/delete reviews, edit capacity ยท + Powered by OceanBase Cloud, Next.js & Vercel +

+
+ + {error &&
{error}
} + {success &&
{success}
} + +
+
+

Courses

+ +
+
+
+ + +
+
+ + +
+
+ + {loading ? ( +
Loading...
+ ) : courses.length === 0 ? ( +
No courses yet
+ ) : ( +
+ {courses.map((course) => ( +
fetchCourseDetails(course.id)}> +
+ {course.code} +
+
{course.name}
+
๐Ÿ‘จโ€๐Ÿซ {course.instructor}
+
๐Ÿ›๏ธ {course.department}
+
๐Ÿ“š {course.credits} credits | ๐Ÿ“… {course.semester}
+
+
+ Enrolled: {course.enrolled} / {course.capacity} +
+
= course.capacity ? '#dc3545' : '#28a745', + fontSize: '0.85rem', + fontWeight: 600 + }}> + {course.enrolled >= course.capacity ? 'Full' : 'Open'} +
+
+
+ ))} +
+ )} +
+ + {showModal && selectedCourse && ( +
setShowModal(false)}> +
e.stopPropagation()}> +
+

{selectedCourse.name}

+ +
+ +
+
+ Code: {selectedCourse.code} +
+
+ Instructor: {selectedCourse.instructor} +
+
+ Department: {selectedCourse.department} +
+
+ Credits: {selectedCourse.credits} | Semester: {selectedCourse.semester} +
+ {selectedCourse.description && ( +
+ Description:
+ {selectedCourse.description} +
+ )} + +
+
+
+ Capacity: {selectedCourse.enrolled} / {selectedCourse.capacity} enrolled +
+
+ setCapacityForm({ capacity: parseInt(e.target.value) || 30 })} + min="1" + style={{ width: '80px', padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px' }} + /> + +
+
+
+ +
+
+

Reviews ({reviews.length})

+ +
+ + {showReviewForm && ( +
+
+ + setReviewForm({ ...reviewForm, student_name: e.target.value })} + required + placeholder="Your name" + /> +
+
+ +
+
+ {[1, 2, 3, 4, 5].map((star) => ( + = star ? 'active' : ''}`} + onClick={() => setReviewForm({ ...reviewForm, rating: star })} + > + โ˜… + + ))} +
+ + {reviewForm.rating > 0 ? `${reviewForm.rating} / 5` : 'Select a rating'} + +
+
+
+ +