Compare commits

..

3 Commits

46 changed files with 9846 additions and 57 deletions

41
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
***REMOVED***
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
***REMOVED****
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

36
frontend/README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -0,0 +1,16 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;

7
frontend/next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

6898
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
frontend/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"db:reset": "ts-node src/scripts/db-reset.ts",
"db:seed": "ts-node prisma/seed.ts",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate deploy"
},
"dependencies": {
"@prisma/client": "^5.8.0",
"next": "14.1.0",
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.0.1",
"eslint": "^8",
"eslint-config-next": "14.1.0",
"postcss": "^8",
"prisma": "^5.8.0",
"tailwindcss": "^3.3.0",
"ts-node": "^10.9.2",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

View File

@@ -0,0 +1,116 @@
-- CreateExtension
CREATE EXTENSION IF NOT EXISTS "postgis";
-- CreateEnum
CREATE TYPE "ParkStatus" AS ENUM ('OPERATING', 'CLOSED_TEMP', 'CLOSED_PERM', 'UNDER_CONSTRUCTION', 'DEMOLISHED', 'RELOCATED');
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL PRIMARY KEY,
"email" TEXT NOT NULL,
"username" TEXT NOT NULL,
"password" TEXT,
"dateJoined" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"isStaff" BOOLEAN NOT NULL DEFAULT false,
"isSuperuser" BOOLEAN NOT NULL DEFAULT false,
"lastLogin" TIMESTAMP(3)
);
-- CreateTable
CREATE TABLE "Park" (
"id" SERIAL PRIMARY KEY,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"status" "ParkStatus" NOT NULL DEFAULT 'OPERATING',
"location" JSONB,
"opening_date" DATE,
"closing_date" DATE,
"operating_season" TEXT,
"size_acres" DECIMAL(10,2),
"website" TEXT,
"average_rating" DECIMAL(3,2),
"ride_count" INTEGER,
"coaster_count" INTEGER,
"creatorId" INTEGER,
"ownerId" INTEGER,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "ParkArea" (
"id" SERIAL PRIMARY KEY,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"opening_date" DATE,
"closing_date" DATE,
"parkId" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "Company" (
"id" SERIAL PRIMARY KEY,
"name" TEXT NOT NULL,
"website" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "Review" (
"id" SERIAL PRIMARY KEY,
"content" TEXT NOT NULL,
"rating" INTEGER NOT NULL,
"parkId" INTEGER NOT NULL,
"userId" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "Photo" (
"id" SERIAL PRIMARY KEY,
"url" TEXT NOT NULL,
"caption" TEXT,
"parkId" INTEGER,
"reviewId" INTEGER,
"userId" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
CREATE UNIQUE INDEX "Park_slug_key" ON "Park"("slug");
CREATE UNIQUE INDEX "ParkArea_parkId_slug_key" ON "ParkArea"("parkId", "slug");
-- AddForeignKey
ALTER TABLE "Park" ADD CONSTRAINT "Park_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "Park" ADD CONSTRAINT "Park_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ParkArea" ADD CONSTRAINT "ParkArea_parkId_fkey" FOREIGN KEY ("parkId") REFERENCES "Park"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Review" ADD CONSTRAINT "Review_parkId_fkey" FOREIGN KEY ("parkId") REFERENCES "Park"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "Review" ADD CONSTRAINT "Review_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_parkId_fkey" FOREIGN KEY ("parkId") REFERENCES "Park"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_reviewId_fkey" FOREIGN KEY ("reviewId") REFERENCES "Review"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- CreateIndex
CREATE INDEX "Park_slug_idx" ON "Park"("slug");
CREATE INDEX "ParkArea_slug_idx" ON "ParkArea"("slug");
CREATE INDEX "Review_parkId_idx" ON "Review"("parkId");
CREATE INDEX "Review_userId_idx" ON "Review"("userId");
CREATE INDEX "Photo_parkId_idx" ON "Photo"("parkId");
CREATE INDEX "Photo_reviewId_idx" ON "Photo"("reviewId");
CREATE INDEX "Photo_userId_idx" ON "Photo"("userId");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -0,0 +1,137 @@
// This is your Prisma schema file
generator client {
provider = "prisma-client-js"
previewFeatures = ["postgresqlExtensions"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
extensions = [postgis]
}
// Core user model
model User {
id Int @id @default(autoincrement())
email String @unique
username String @unique
password String?
dateJoined DateTime @default(now())
isActive Boolean @default(true)
isStaff Boolean @default(false)
isSuperuser Boolean @default(false)
lastLogin DateTime?
createdParks Park[] @relation("ParkCreator")
reviews Review[]
photos Photo[]
}
// Park model
model Park {
id Int @id @default(autoincrement())
name String
slug String @unique
description String? @db.Text
status ParkStatus @default(OPERATING)
location Json? // Store PostGIS point as JSON for now
// Details
opening_date DateTime? @db.Date
closing_date DateTime? @db.Date
operating_season String?
size_acres Decimal? @db.Decimal(10, 2)
website String?
// Statistics
average_rating Decimal? @db.Decimal(3, 2)
ride_count Int?
coaster_count Int?
// Relationships
creator User? @relation("ParkCreator", fields: [creatorId], references: [id])
creatorId Int?
owner Company? @relation(fields: [ownerId], references: [id])
ownerId Int?
areas ParkArea[]
reviews Review[]
photos Photo[]
// Metadata
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@index([slug])
}
// Park Area model
model ParkArea {
id Int @id @default(autoincrement())
name String
slug String
description String? @db.Text
opening_date DateTime? @db.Date
closing_date DateTime? @db.Date
park Park @relation(fields: [parkId], references: [id], onDelete: Cascade)
parkId Int
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@unique([parkId, slug])
@@index([slug])
}
// Company model (for park owners)
model Company {
id Int @id @default(autoincrement())
name String
website String?
parks Park[]
created_at DateTime @default(now())
updated_at DateTime @updatedAt
}
// Review model
model Review {
id Int @id @default(autoincrement())
content String @db.Text
rating Int
park Park @relation(fields: [parkId], references: [id])
parkId Int
user User @relation(fields: [userId], references: [id])
userId Int
photos Photo[]
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@index([parkId])
@@index([userId])
}
// Photo model
model Photo {
id Int @id @default(autoincrement())
url String
caption String?
park Park? @relation(fields: [parkId], references: [id])
parkId Int?
review Review? @relation(fields: [reviewId], references: [id])
reviewId Int?
user User @relation(fields: [userId], references: [id])
userId Int
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@index([parkId])
@@index([reviewId])
@@index([userId])
}
enum ParkStatus {
OPERATING
CLOSED_TEMP
CLOSED_PERM
UNDER_CONSTRUCTION
DEMOLISHED
RELOCATED
}

148
frontend/prisma/seed.ts Normal file
View File

@@ -0,0 +1,148 @@
import { PrismaClient, ParkStatus } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
// Create test users
const user1 = await prisma.user.create({
data: {
email: 'admin@thrillwiki.com',
username: 'admin',
password: 'hashed_password_here',
isActive: true,
isStaff: true,
isSuperuser: true,
},
});
const user2 = await prisma.user.create({
data: {
email: 'testuser@thrillwiki.com',
username: 'testuser',
password: 'hashed_password_here',
isActive: true,
},
});
// Create test companies
const company1 = await prisma.company.create({
data: {
name: 'Universal Parks & Resorts',
website: 'https://www.universalparks.com',
},
});
const company2 = await prisma.company.create({
data: {
name: 'Cedar Fair Entertainment Company',
website: 'https://www.cedarfair.com',
},
});
// Create test parks
const park1 = await prisma.park.create({
data: {
name: 'Universal Studios Florida',
slug: 'universal-studios-florida',
description: 'Movie and TV-based theme park in Orlando, Florida',
status: ParkStatus.OPERATING,
location: {
latitude: 28.4742,
longitude: -81.4678,
},
opening_date: new Date('1990-06-07'),
operating_season: 'Year-round',
size_acres: 108,
website: 'https://www.universalorlando.com',
average_rating: 4.5,
ride_count: 25,
coaster_count: 2,
creatorId: user1.id,
ownerId: company1.id,
areas: {
create: [
{
name: 'Production Central',
slug: 'production-central',
description: 'The main entrance area of the park',
opening_date: new Date('1990-06-07'),
},
{
name: 'New York',
slug: 'new-york',
description: 'Themed after New York City streets',
opening_date: new Date('1990-06-07'),
},
],
},
},
});
const park2 = await prisma.park.create({
data: {
name: 'Cedar Point',
slug: 'cedar-point',
description: 'The Roller Coaster Capital of the World',
status: ParkStatus.OPERATING,
location: {
latitude: 41.4822,
longitude: -82.6837,
},
opening_date: new Date('1870-05-01'),
operating_season: 'May through October',
size_acres: 364,
website: 'https://www.cedarpoint.com',
average_rating: 4.8,
ride_count: 71,
coaster_count: 17,
creatorId: user1.id,
ownerId: company2.id,
areas: {
create: [
{
name: 'FrontierTown',
slug: 'frontiertown',
description: 'Western-themed area of the park',
opening_date: new Date('1968-05-01'),
},
{
name: 'Millennium Island',
slug: 'millennium-island',
description: 'Home of the Millennium Force',
opening_date: new Date('2000-05-13'),
},
],
},
},
});
// Create test reviews
await prisma.review.create({
data: {
content: 'Amazing theme park with great attention to detail!',
rating: 5,
parkId: park1.id,
userId: user2.id,
},
});
await prisma.review.create({
data: {
content: 'Best roller coasters in the world!',
rating: 5,
parkId: park2.id,
userId: user2.id,
},
});
console.log('Seed data created successfully');
}
main()
.catch((e) => {
console.error('Error creating seed data:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

1
frontend/public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
frontend/public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,103 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import { ParkDetailResponse } from '@/types/api';
export async function GET(
request: NextRequest,
{ params }: { params: { slug: string } }
): Promise<NextResponse<ParkDetailResponse>> {
try {
// Ensure database connection is initialized
if (!prisma) {
throw new Error('Database connection not initialized');
}
// Find park by slug with all relationships
const park = await prisma.park.findUnique({
where: { slug: params.slug },
include: {
creator: {
select: {
id: true,
username: true,
email: true,
},
},
owner: true,
areas: true,
reviews: {
include: {
user: {
select: {
id: true,
username: true,
},
},
},
},
photos: {
include: {
user: {
select: {
id: true,
username: true,
},
},
},
},
},
});
// Return 404 if park not found
if (!park) {
return NextResponse.json(
{
success: false,
error: 'Park not found',
},
{ status: 404 }
);
}
// Format dates consistently with list endpoint
const formattedPark = {
...park,
opening_date: park.opening_date?.toISOString().split('T')[0],
closing_date: park.closing_date?.toISOString().split('T')[0],
created_at: park.created_at.toISOString(),
updated_at: park.updated_at.toISOString(),
// Format nested dates
areas: park.areas.map(area => ({
...area,
opening_date: area.opening_date?.toISOString().split('T')[0],
closing_date: area.closing_date?.toISOString().split('T')[0],
created_at: area.created_at.toISOString(),
updated_at: area.updated_at.toISOString(),
})),
reviews: park.reviews.map(review => ({
...review,
created_at: review.created_at.toISOString(),
updated_at: review.updated_at.toISOString(),
})),
photos: park.photos.map(photo => ({
...photo,
created_at: photo.created_at.toISOString(),
updated_at: photo.updated_at.toISOString(),
})),
};
return NextResponse.json({
success: true,
data: formattedPark,
});
} catch (error) {
console.error('Error fetching park:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch park',
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,88 @@
import { NextResponse } from 'next/server';
import { Prisma } from '@prisma/client';
import prisma from '@/lib/prisma';
export async function GET(request: Request) {
try {
// Test raw query first
try {
console.log('Testing database connection...');
const rawResult = await prisma.$queryRaw`SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname = 'public'`;
console.log('Available tables:', rawResult);
} catch (connectionError) {
console.error('Raw query test failed:', connectionError);
throw new Error('Database connection test failed');
}
// Basic query with explicit types
try {
const queryResult = await prisma.$transaction(async (tx) => {
// Count total parks
const totalCount = await tx.park.count();
console.log('Total parks count:', totalCount);
// Fetch parks with minimal fields
const parks = await tx.park.findMany({
take: 10,
select: {
id: true,
name: true,
slug: true,
status: true,
owner: {
select: {
id: true,
name: true
}
}
},
orderBy: {
name: 'asc'
}
} satisfies Prisma.ParkFindManyArgs);
return { totalCount, parks };
});
return NextResponse.json({
success: true,
data: queryResult.parks,
meta: {
total: queryResult.totalCount
}
});
} catch (queryError) {
if (queryError instanceof Prisma.PrismaClientKnownRequestError) {
console.error('Known Prisma error:', {
code: queryError.code,
meta: queryError.meta,
message: queryError.message
});
throw new Error(`Database query failed: ${queryError.code}`);
}
throw queryError;
}
} catch (error) {
console.error('Error in /api/parks:', {
name: error instanceof Error ? error.name : 'Unknown',
message: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : undefined
});
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch parks'
},
{
status: 500,
headers: {
'Cache-Control': 'no-store, must-revalidate',
'Content-Type': 'application/json'
}
}
);
}
}

View File

@@ -0,0 +1,50 @@
import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const search = searchParams.get('search')?.trim();
if (!search) {
return NextResponse.json({
success: true,
data: []
});
}
const parks = await prisma.park.findMany({
where: {
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ owner: { name: { contains: search, mode: 'insensitive' } } }
]
},
select: {
id: true,
name: true,
slug: true,
status: true,
owner: {
select: {
name: true,
slug: true
}
}
},
take: 8 // Limit quick search results like Django
});
return NextResponse.json({
success: true,
data: parks
});
} catch (error) {
console.error('Error in /api/parks/suggest:', error);
return NextResponse.json({
success: false,
error: 'Failed to fetch park suggestions'
}, { status: 500 });
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,21 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
}

View File

@@ -0,0 +1,62 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "ThrillWiki - Theme Park Information & Reviews",
description: "Discover theme parks, share experiences, and read reviews from park enthusiasts.",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
<div className="min-h-screen bg-white">
<header className="bg-indigo-600">
<nav className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8" aria-label="Top">
<div className="flex w-full items-center justify-between border-b border-indigo-500 py-6">
<div className="flex items-center">
<a href="/" className="text-white text-2xl font-bold">
ThrillWiki
</a>
</div>
<div className="ml-10 space-x-4">
<a
href="/parks"
className="inline-block rounded-md border border-transparent bg-indigo-500 py-2 px-4 text-base font-medium text-white hover:bg-opacity-75"
>
Parks
</a>
<a
href="/login"
className="inline-block rounded-md border border-transparent bg-white py-2 px-4 text-base font-medium text-indigo-600 hover:bg-indigo-50"
>
Login
</a>
</div>
</div>
</nav>
</header>
{children}
<footer className="bg-white">
<div className="mx-auto max-w-7xl px-6 py-12 md:flex md:items-center md:justify-between lg:px-8">
<div className="mt-8 md:order-1 md:mt-0">
<p className="text-center text-xs leading-5 text-gray-500">
&copy; {new Date().getFullYear()} ThrillWiki. All rights reserved.
</p>
</div>
</div>
</footer>
</div>
</body>
</html>
);
}

84
frontend/src/app/page.tsx Normal file
View File

@@ -0,0 +1,84 @@
'use client';
import { useEffect, useState } from 'react';
import type { Park } from '@/types/api';
export default function Home() {
const [parks, setParks] = useState<Park[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function fetchParks() {
try {
const response = await fetch('/api/parks');
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to fetch parks');
}
setParks(data.data || []);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
}
fetchParks();
}, []);
if (loading) {
return (
<main className="min-h-screen p-8">
<div className="text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]" />
<p className="mt-4 text-gray-600">Loading parks...</p>
</div>
</main>
);
}
if (error) {
return (
<main className="min-h-screen p-8">
<div className="rounded-lg bg-red-50 p-4 border border-red-200">
<h2 className="text-red-800 font-semibold">Error</h2>
<p className="text-red-600 mt-2">{error}</p>
</div>
</main>
);
}
return (
<main className="min-h-screen p-8">
<h1 className="text-3xl font-bold mb-8">ThrillWiki Parks</h1>
{parks.length === 0 ? (
<p className="text-gray-600">No parks found</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{parks.map((park) => (
<div
key={park.id}
className="rounded-lg border border-gray-200 p-6 hover:shadow-lg transition-shadow"
>
<h2 className="text-xl font-semibold mb-2">{park.name}</h2>
{park.description && (
<p className="text-gray-600 mb-4">
{park.description.length > 150
? `${park.description.slice(0, 150)}...`
: park.description}
</p>
)}
<div className="text-sm text-gray-500">
Added: {new Date(park.createdAt).toLocaleDateString()}
</div>
</div>
))}
</div>
)}
</main>
);
}

View File

@@ -0,0 +1,99 @@
export default function ParkDetailLoading() {
return (
<main className="container mx-auto px-4 py-8 animate-pulse">
<article className="bg-white rounded-lg shadow-lg p-6">
{/* Header skeleton */}
<header className="mb-8">
<div className="h-10 w-3/4 bg-gray-200 rounded mb-4"></div>
<div className="flex items-center gap-4">
<div className="h-6 w-24 bg-gray-200 rounded-full"></div>
<div className="h-6 w-32 bg-gray-200 rounded"></div>
</div>
</header>
{/* Description skeleton */}
<section className="mb-8">
<div className="space-y-2">
<div className="h-4 bg-gray-200 rounded w-full"></div>
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
<div className="h-4 bg-gray-200 rounded w-4/6"></div>
</div>
</section>
{/* Details skeleton */}
<section className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
<div>
<div className="h-8 w-32 bg-gray-200 rounded mb-4"></div>
<div className="space-y-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="grid grid-cols-2 gap-4">
<div className="h-6 bg-gray-200 rounded"></div>
<div className="h-6 bg-gray-200 rounded"></div>
</div>
))}
</div>
</div>
<div>
<div className="h-8 w-32 bg-gray-200 rounded mb-4"></div>
<div className="bg-gray-100 p-4 rounded-lg">
<div className="space-y-2">
<div className="h-6 bg-gray-200 rounded"></div>
<div className="h-6 bg-gray-200 rounded"></div>
</div>
</div>
</div>
</section>
{/* Areas skeleton */}
<section className="mb-8">
<div className="h-8 w-32 bg-gray-200 rounded mb-4"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="bg-gray-50 p-4 rounded-lg">
<div className="h-6 bg-gray-200 rounded mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
</div>
))}
</div>
</section>
{/* Reviews skeleton */}
<section className="mb-8">
<div className="h-8 w-32 bg-gray-200 rounded mb-4"></div>
<div className="space-y-4">
{[...Array(2)].map((_, i) => (
<div key={i} className="bg-gray-50 p-4 rounded-lg">
<div className="flex justify-between items-start mb-2">
<div className="space-y-2">
<div className="h-6 bg-gray-200 rounded w-32"></div>
<div className="h-4 bg-gray-200 rounded w-24"></div>
</div>
<div className="h-6 w-24 bg-gray-200 rounded"></div>
</div>
<div className="space-y-2 mt-4">
<div className="h-4 bg-gray-200 rounded w-full"></div>
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
</div>
<div className="mt-2 flex gap-2">
{[...Array(2)].map((_, j) => (
<div key={j} className="w-24 h-24 bg-gray-200 rounded"></div>
))}
</div>
</div>
))}
</div>
</section>
{/* Photos skeleton */}
<section>
<div className="h-8 w-32 bg-gray-200 rounded mb-4"></div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{[...Array(8)].map((_, i) => (
<div key={i} className="aspect-square bg-gray-200 rounded-lg"></div>
))}
</div>
</section>
</article>
</main>
);
}

View File

@@ -0,0 +1,194 @@
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { Park, ParkDetailResponse } from '@/types/api';
// Dynamically generate metadata for park pages
export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
try {
const park = await fetchParkData(params.slug);
return {
title: `${park.name} | ThrillWiki`,
description: park.description || `Details about ${park.name}`,
};
} catch (error) {
return {
title: 'Park Not Found | ThrillWiki',
description: 'The requested park could not be found.',
};
}
}
// Fetch park data from API
async function fetchParkData(slug: string): Promise<Park> {
const response = await fetch(`${process***REMOVED***.NEXT_PUBLIC_API_URL}/api/parks/${slug}`, {
next: { revalidate: 60 }, // Cache for 1 minute
});
if (!response.ok) {
if (response.status === 404) {
notFound();
}
throw new Error('Failed to fetch park data');
}
const { data }: ParkDetailResponse = await response.json();
return data;
}
// Park detail page component
export default async function ParkDetailPage({ params }: { params: { slug: string } }) {
const park = await fetchParkData(params.slug);
return (
<main className="container mx-auto px-4 py-8">
<article className="bg-white rounded-lg shadow-lg p-6">
{/* Park header */}
<header className="mb-8">
<h1 className="text-4xl font-bold mb-4">{park.name}</h1>
<div className="flex items-center gap-4 text-gray-600">
<span className={`px-3 py-1 rounded-full text-sm ${
park.status === 'OPERATING' ? 'bg-green-100 text-green-800' :
park.status === 'CLOSED_TEMP' ? 'bg-yellow-100 text-yellow-800' :
park.status === 'UNDER_CONSTRUCTION' ? 'bg-blue-100 text-blue-800' :
'bg-red-100 text-red-800'
}`}>
{park.status.replace('_', ' ')}
</span>
{park.opening_date && (
<span>Opened: {park.opening_date}</span>
)}
</div>
</header>
{/* Park description */}
{park.description && (
<section className="mb-8">
<p className="text-gray-700 leading-relaxed">{park.description}</p>
</section>
)}
{/* Park details */}
<section className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
<div>
<h2 className="text-2xl font-semibold mb-4">Details</h2>
<dl className="grid grid-cols-2 gap-4">
{park.size_acres && (
<>
<dt className="font-medium text-gray-600">Size</dt>
<dd>{park.size_acres} acres</dd>
</>
)}
{park.operating_season && (
<>
<dt className="font-medium text-gray-600">Season</dt>
<dd>{park.operating_season}</dd>
</>
)}
{park.ride_count && (
<>
<dt className="font-medium text-gray-600">Total Rides</dt>
<dd>{park.ride_count}</dd>
</>
)}
{park.coaster_count && (
<>
<dt className="font-medium text-gray-600">Roller Coasters</dt>
<dd>{park.coaster_count}</dd>
</>
)}
{park.average_rating && (
<>
<dt className="font-medium text-gray-600">Average Rating</dt>
<dd>{park.average_rating.toFixed(1)} / 5.0</dd>
</>
)}
</dl>
</div>
{/* Location */}
{park.location && (
<div>
<h2 className="text-2xl font-semibold mb-4">Location</h2>
<div className="bg-gray-100 p-4 rounded-lg">
<p>Latitude: {park.location.latitude}</p>
<p>Longitude: {park.location.longitude}</p>
</div>
</div>
)}
</section>
{/* Areas */}
{park.areas.length > 0 && (
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">Areas</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{park.areas.map(area => (
<div key={area.id} className="bg-gray-50 p-4 rounded-lg">
<h3 className="font-semibold mb-2">{area.name}</h3>
{area.description && (
<p className="text-sm text-gray-600">{area.description}</p>
)}
</div>
))}
</div>
</section>
)}
{/* Reviews */}
{park.reviews.length > 0 && (
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">Reviews</h2>
<div className="space-y-4">
{park.reviews.map(review => (
<div key={review.id} className="bg-gray-50 p-4 rounded-lg">
<div className="flex justify-between items-start mb-2">
<div>
<span className="font-medium">{review.user.username}</span>
<span className="text-gray-600 text-sm ml-2">
{new Date(review.created_at).toLocaleDateString()}
</span>
</div>
<div className="text-yellow-500">{
'★'.repeat(review.rating) + '☆'.repeat(5 - review.rating)
}</div>
</div>
<p className="text-gray-700">{review.content}</p>
{review.photos.length > 0 && (
<div className="mt-2 flex gap-2 flex-wrap">
{review.photos.map(photo => (
<img
key={photo.id}
src={photo.url}
alt={photo.caption || `Photo by ${photo.user.username}`}
className="w-24 h-24 object-cover rounded"
/>
))}
</div>
)}
</div>
))}
</div>
</section>
)}
{/* Photos */}
{park.photos.length > 0 && (
<section>
<h2 className="text-2xl font-semibold mb-4">Photos</h2>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{park.photos.map(photo => (
<div key={photo.id} className="aspect-square relative">
<img
src={photo.url}
alt={photo.caption || `Photo of ${park.name}`}
className="absolute inset-0 w-full h-full object-cover rounded-lg"
/>
</div>
))}
</div>
</section>
)}
</article>
</main>
);
}

View File

@@ -0,0 +1,150 @@
'use client';
import { useEffect, useState } from 'react';
import type { Park, ParkFilterValues, Company } from '@/types/api';
import { ParkSearch } from '@/components/parks/ParkSearch';
import { ViewToggle } from '@/components/parks/ViewToggle';
import { ParkList } from '@/components/parks/ParkList';
import { ParkFilters } from '@/components/parks/ParkFilters';
export default function ParksPage() {
const [parks, setParks] = useState<Park[]>([]);
const [companies, setCompanies] = useState<Company[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [searchQuery, setSearchQuery] = useState('');
const [filters, setFilters] = useState<ParkFilterValues>({});
// Fetch companies for filter dropdown
useEffect(() => {
async function fetchCompanies() {
try {
const response = await fetch('/api/companies');
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to fetch companies');
}
setCompanies(data.data || []);
} catch (err) {
console.error('Failed to fetch companies:', err);
// Don't set error state for companies - just show empty list
setCompanies([]);
}
}
fetchCompanies();
}, []);
// Fetch parks with filters
useEffect(() => {
async function fetchParks() {
try {
setLoading(true);
setError(null);
const queryParams = new URLSearchParams();
// Only add defined parameters
if (searchQuery?.trim()) queryParams.set('search', searchQuery.trim());
if (filters.status) queryParams.set('status', filters.status);
if (filters.ownerId) queryParams.set('ownerId', filters.ownerId);
if (filters.hasOwner !== undefined) queryParams.set('hasOwner', filters.hasOwner.toString());
if (filters.minRides) queryParams.set('minRides', filters.minRides.toString());
if (filters.minCoasters) queryParams.set('minCoasters', filters.minCoasters.toString());
if (filters.minSize) queryParams.set('minSize', filters.minSize.toString());
if (filters.openingDateStart) queryParams.set('openingDateStart', filters.openingDateStart);
if (filters.openingDateEnd) queryParams.set('openingDateEnd', filters.openingDateEnd);
const response = await fetch(`/api/parks?${queryParams}`);
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to fetch parks');
}
setParks(data.data || []);
setError(null);
} catch (err) {
console.error('Error fetching parks:', err);
setError(err instanceof Error ? err.message : 'An error occurred while fetching parks');
setParks([]);
} finally {
setLoading(false);
}
}
fetchParks();
}, [searchQuery, filters]);
const handleSearch = (query: string) => {
setSearchQuery(query);
};
const handleFiltersChange = (newFilters: ParkFilterValues) => {
setFilters(newFilters);
};
if (loading) {
return (
<div className="min-h-screen p-8">
<div className="text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]" />
<p className="mt-4 text-gray-600">Loading parks...</p>
</div>
</div>
);
}
if (error && !parks.length) {
return (
<div className="p-4" data-testid="park-list-error">
<div className="inline-flex items-center px-4 py-2 rounded-md bg-red-50 text-red-700">
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd"/>
</svg>
{error}
</div>
</div>
);
}
return (
<div className="min-h-screen p-8">
<div className="max-w-7xl mx-auto">
<div className="flex justify-between items-center mb-6">
<div className="flex items-center space-x-4">
<h1 className="text-2xl font-bold text-gray-900">Parks</h1>
<ViewToggle currentView={viewMode} onViewChange={setViewMode} />
</div>
</div>
<div className="mb-6">
<ParkSearch onSearch={handleSearch} />
<ParkFilters onFiltersChange={handleFiltersChange} companies={companies} />
</div>
<div
id="park-results"
className="bg-white rounded-lg shadow overflow-hidden"
data-view-mode={viewMode}
>
<div className="transition-all duration-300 ease-in-out">
<ParkList
parks={parks}
viewMode={viewMode}
searchQuery={searchQuery}
/>
</div>
</div>
{error && parks.length > 0 && (
<div className="mt-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg text-yellow-800">
<p className="text-sm">
Some data might be incomplete or outdated: {error}
</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,81 @@
'use client';
import { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false
};
public static getDerivedStateFromError(error: Error): State {
return {
hasError: true,
error
};
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo);
}
public render() {
if (this.state.hasError) {
return this.props.fallback || (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div className="rounded-md bg-red-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg
className="h-5 w-5 text-red-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">
Something went wrong
</h3>
{this.state.error && (
<div className="mt-2 text-sm text-red-700">
<p>{this.state.error.message}</p>
</div>
)}
<div className="mt-4">
<button
type="button"
onClick={() => window.location.reload()}
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>
Retry
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,55 @@
import Link from 'next/link';
import type { Park } from '@/types/api';
interface ParkCardProps {
park: Park;
}
function getStatusBadgeClass(status: string): string {
const statusClasses = {
OPERATING: 'bg-green-100 text-green-800',
CLOSED_TEMP: 'bg-yellow-100 text-yellow-800',
CLOSED_PERM: 'bg-red-100 text-red-800',
UNDER_CONSTRUCTION: 'bg-blue-100 text-blue-800',
DEMOLISHED: 'bg-gray-100 text-gray-800',
RELOCATED: 'bg-purple-100 text-purple-800'
};
return statusClasses[status as keyof typeof statusClasses] || 'bg-gray-100 text-gray-500';
}
export function ParkCard({ park }: ParkCardProps) {
const statusClass = getStatusBadgeClass(park.status);
const formattedStatus = park.status.replace(/_/g, ' ');
return (
<div className="overflow-hidden transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
<div className="p-4">
<h2 className="mb-2 text-xl font-bold">
<Link
href={`/parks/${park.slug}`}
className="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400"
>
{park.name}
</Link>
</h2>
<div className="flex flex-wrap gap-2">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${statusClass}`}>
{formattedStatus}
</span>
</div>
{park.owner && (
<div className="mt-4 text-sm">
<Link
href={`/companies/${park.owner.slug}`}
className="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{park.owner.name}
</Link>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,182 @@
'use client';
import { useState } from 'react';
import type { Company, ParkStatus } from '@/types/api';
const STATUS_OPTIONS = {
OPERATING: 'Operating',
CLOSED_TEMP: 'Temporarily Closed',
CLOSED_PERM: 'Permanently Closed',
UNDER_CONSTRUCTION: 'Under Construction',
DEMOLISHED: 'Demolished',
RELOCATED: 'Relocated'
} as const;
interface ParkFiltersProps {
onFiltersChange: (filters: ParkFilterValues) => void;
companies: Company[];
}
interface ParkFilterValues {
status?: ParkStatus;
ownerId?: string;
hasOwner?: boolean;
minRides?: number;
minCoasters?: number;
minSize?: number;
openingDateStart?: string;
openingDateEnd?: string;
}
export function ParkFilters({ onFiltersChange, companies }: ParkFiltersProps) {
const [filters, setFilters] = useState<ParkFilterValues>({});
const handleFilterChange = (field: keyof ParkFilterValues, value: any) => {
const newFilters = {
...filters,
[field]: value === '' ? undefined : value
};
setFilters(newFilters);
onFiltersChange(newFilters);
};
return (
<div className="bg-white shadow sm:rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg font-medium leading-6 text-gray-900">Filters</h3>
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{/* Status Filter */}
<div>
<label htmlFor="status" className="block text-sm font-medium text-gray-700">
Operating Status
</label>
<select
id="status"
name="status"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
value={filters.status || ''}
onChange={(e) => handleFilterChange('status', e.target.value)}
>
<option value="">Any status</option>
{Object.entries(STATUS_OPTIONS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
{/* Owner Filter */}
<div>
<label htmlFor="owner" className="block text-sm font-medium text-gray-700">
Operating Company
</label>
<select
id="owner"
name="owner"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
value={filters.ownerId || ''}
onChange={(e) => handleFilterChange('ownerId', e.target.value)}
>
<option value="">Any company</option>
{companies.map((company) => (
<option key={company.id} value={company.id}>
{company.name}
</option>
))}
</select>
</div>
{/* Has Owner Filter */}
<div>
<label htmlFor="hasOwner" className="block text-sm font-medium text-gray-700">
Company Status
</label>
<select
id="hasOwner"
name="hasOwner"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
value={filters.hasOwner === undefined ? '' : filters.hasOwner.toString()}
onChange={(e) => handleFilterChange('hasOwner', e.target.value === '' ? undefined : e.target.value === 'true')}
>
<option value="">Show all</option>
<option value="true">Has company</option>
<option value="false">No company</option>
</select>
</div>
{/* Min Rides Filter */}
<div>
<label htmlFor="minRides" className="block text-sm font-medium text-gray-700">
Minimum Rides
</label>
<input
type="number"
id="minRides"
name="minRides"
min="0"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
value={filters.minRides || ''}
onChange={(e) => handleFilterChange('minRides', e.target.value ? parseInt(e.target.value, 10) : '')}
/>
</div>
{/* Min Coasters Filter */}
<div>
<label htmlFor="minCoasters" className="block text-sm font-medium text-gray-700">
Minimum Roller Coasters
</label>
<input
type="number"
id="minCoasters"
name="minCoasters"
min="0"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
value={filters.minCoasters || ''}
onChange={(e) => handleFilterChange('minCoasters', e.target.value ? parseInt(e.target.value, 10) : '')}
/>
</div>
{/* Min Size Filter */}
<div>
<label htmlFor="minSize" className="block text-sm font-medium text-gray-700">
Minimum Size (acres)
</label>
<input
type="number"
id="minSize"
name="minSize"
min="0"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
value={filters.minSize || ''}
onChange={(e) => handleFilterChange('minSize', e.target.value ? parseInt(e.target.value, 10) : '')}
/>
</div>
{/* Opening Date Range */}
<div className="sm:col-span-2 lg:col-span-3">
<label className="block text-sm font-medium text-gray-700">Opening Date Range</label>
<div className="mt-1 grid grid-cols-2 gap-4">
<input
type="date"
id="openingDateStart"
name="openingDateStart"
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
value={filters.openingDateStart || ''}
onChange={(e) => handleFilterChange('openingDateStart', e.target.value)}
/>
<input
type="date"
id="openingDateEnd"
name="openingDateEnd"
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
value={filters.openingDateEnd || ''}
onChange={(e) => handleFilterChange('openingDateEnd', e.target.value)}
/>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import type { Park } from '@/types/api';
import { ParkCard } from './ParkCard';
import { ParkListItem } from './ParkListItem';
interface ParkListProps {
parks: Park[];
viewMode: 'grid' | 'list';
searchQuery?: string;
}
export function ParkList({ parks, viewMode, searchQuery }: ParkListProps) {
if (parks.length === 0) {
return (
<div className="col-span-full p-4 text-sm text-gray-500 text-center" data-testid="no-parks-found">
{searchQuery ? (
<>No parks found matching "{searchQuery}". Try adjusting your search terms.</>
) : (
<>No parks found matching your criteria. Try adjusting your filters.</>
)}
</div>
);
}
if (viewMode === 'list') {
return (
<div className="divide-y divide-gray-200">
{parks.map((park) => (
<ParkListItem key={park.id} park={park} />
))}
</div>
);
}
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{parks.map((park) => (
<ParkCard key={park.id} park={park} />
))}
</div>
);
}

View File

@@ -0,0 +1,70 @@
import Link from 'next/link';
import type { Park } from '@/types/api';
interface ParkListItemProps {
park: Park;
}
function getStatusBadgeClass(status: string): string {
const statusClasses = {
OPERATING: 'bg-green-100 text-green-800',
CLOSED_TEMP: 'bg-yellow-100 text-yellow-800',
CLOSED_PERM: 'bg-red-100 text-red-800',
UNDER_CONSTRUCTION: 'bg-blue-100 text-blue-800',
DEMOLISHED: 'bg-gray-100 text-gray-800',
RELOCATED: 'bg-purple-100 text-purple-800'
};
return statusClasses[status as keyof typeof statusClasses] || 'bg-gray-100 text-gray-500';
}
export function ParkListItem({ park }: ParkListItemProps) {
const statusClass = getStatusBadgeClass(park.status);
const formattedStatus = park.status.replace(/_/g, ' ');
return (
<div className="flex flex-col sm:flex-row sm:items-center justify-between p-4 border-b border-gray-200 last:border-b-0">
<div className="flex-1">
<div className="flex items-start justify-between">
<h2 className="text-lg font-semibold mb-1">
<Link
href={`/parks/${park.slug}`}
className="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400"
>
{park.name}
</Link>
</h2>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${statusClass}`}>
{formattedStatus}
</span>
</div>
{park.owner && (
<div className="text-sm mb-2">
<Link
href={`/companies/${park.owner.slug}`}
className="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{park.owner.name}
</Link>
</div>
)}
<div className="text-sm text-gray-600 space-x-4">
{park.location && (
<span>
{[
park.location.city,
park.location.state,
park.location.country
].filter(Boolean).join(', ')}
</span>
)}
<span>{park.ride_count} rides</span>
{park.opening_date && (
<span>Opened: {new Date(park.opening_date).toLocaleDateString()}</span>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,105 @@
'use client';
import { useState, useCallback } from 'react';
import debounce from 'lodash/debounce';
interface ParkSearchProps {
onSearch: (query: string) => void;
}
export function ParkSearch({ onSearch }: ParkSearchProps) {
const [query, setQuery] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [suggestions, setSuggestions] = useState<Array<{ id: string; name: string; slug: string }>>([]);
const debouncedFetchSuggestions = useCallback(
debounce(async (searchQuery: string) => {
if (!searchQuery.trim()) {
setSuggestions([]);
return;
}
try {
setIsLoading(true);
const response = await fetch(`/api/parks/suggest?search=${encodeURIComponent(searchQuery)}`);
const data = await response.json();
if (data.success) {
setSuggestions(data.data || []);
}
} catch (error) {
console.error('Failed to fetch suggestions:', error);
setSuggestions([]);
} finally {
setIsLoading(false);
}
}, 300),
[]
);
const handleSearch = (searchQuery: string) => {
setQuery(searchQuery);
debouncedFetchSuggestions(searchQuery);
onSearch(searchQuery);
};
const handleSuggestionClick = (suggestion: { name: string; slug: string }) => {
setQuery(suggestion.name);
setSuggestions([]);
onSearch(suggestion.name);
};
return (
<div className="max-w-3xl mx-auto relative mb-8">
<div className="w-full relative">
<div className="relative">
<input
type="search"
value={query}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Search parks..."
className="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
aria-label="Search parks"
aria-controls="search-results"
aria-expanded={suggestions.length > 0}
/>
{isLoading && (
<div
className="absolute right-3 top-1/2 -translate-y-1/2"
role="status"
aria-label="Loading search results"
>
<svg className="w-5 h-5 text-gray-400 animate-spin" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
<span className="sr-only">Searching...</span>
</div>
)}
</div>
{suggestions.length > 0 && (
<div
id="search-results"
className="absolute z-50 mt-1 w-full bg-white dark:bg-gray-800 rounded-md shadow-lg"
role="listbox"
>
<ul>
{suggestions.map((suggestion) => (
<li
key={suggestion.id}
className="px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer"
role="option"
onClick={() => handleSuggestionClick(suggestion)}
>
{suggestion.name}
</li>
))}
</ul>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
'use client';
interface ViewToggleProps {
currentView: 'grid' | 'list';
onViewChange: (view: 'grid' | 'list') => void;
}
export function ViewToggle({ currentView, onViewChange }: ViewToggleProps) {
return (
<fieldset className="flex items-center space-x-2 bg-gray-100 rounded-lg p-1">
<legend className="sr-only">View mode selection</legend>
<button
onClick={() => onViewChange('grid')}
className={`p-2 rounded transition-colors duration-200 ${
currentView === 'grid' ? 'bg-white shadow-sm' : ''
}`}
aria-label="Grid view"
aria-pressed={currentView === 'grid'}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 6h16M4 10h16M4 14h16M4 18h16"
/>
</svg>
</button>
<button
onClick={() => onViewChange('list')}
className={`p-2 rounded transition-colors duration-200 ${
currentView === 'list' ? 'bg-white shadow-sm' : ''
}`}
aria-label="List view"
aria-pressed={currentView === 'list'}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 6h16M4 12h7"
/>
</svg>
</button>
</fieldset>
);
}

View File

@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export async function middleware(request: NextRequest) {
const response = NextResponse.next();
// Add additional headers
response.headers.set('x-middleware-cache', 'no-cache');
// CORS headers for API routes
if (request.nextUrl.pathname.startsWith('/api/')) {
response.headers.set('Access-Control-Allow-Origin', '*');
response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
}
return response;
}
// Configure routes that need middleware
export const config = {
matcher: [
'/api/:path*',
]
};

View File

@@ -0,0 +1,38 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function reset() {
try {
console.log('Starting database reset...');
// Drop all tables in the correct order
const tableOrder = [
'Photo',
'Review',
'ParkArea',
'Park',
'Company',
'User'
];
for (const table of tableOrder) {
console.log(`Deleting all records from ${table}...`);
// @ts-ignore - Dynamic table name
await prisma[table.toLowerCase()].deleteMany();
}
console.log('Database reset complete. Running seed...');
// Run the seed script
await import('../prisma/seed');
console.log('Database reset and seed completed successfully');
} catch (error) {
console.error('Error during database reset:', error);
process.exit(1);
} finally {
await prisma.$disconnect();
}
}
reset();

83
frontend/src/types/api.ts Normal file
View File

@@ -0,0 +1,83 @@
// General API response types
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
}
// Park status type
export type ParkStatus =
| 'OPERATING'
| 'CLOSED_TEMP'
| 'CLOSED_PERM'
| 'UNDER_CONSTRUCTION'
| 'DEMOLISHED'
| 'RELOCATED';
// Company (owner) type
export interface Company {
id: string;
name: string;
slug: string;
}
// Location type
export interface Location {
id: string;
city?: string;
state?: string;
country?: string;
postal_code?: string;
street_address?: string;
latitude?: number;
longitude?: number;
}
// Park type
export interface Park {
id: string;
name: string;
slug: string;
description?: string;
status: ParkStatus;
owner?: Company;
location?: Location;
opening_date?: string;
closing_date?: string;
operating_season?: string;
website?: string;
size_acres?: number;
ride_count: number;
coaster_count?: number;
average_rating?: number;
}
// Park filter values type
export interface ParkFilterValues {
search?: string;
status?: ParkStatus;
ownerId?: string;
hasOwner?: boolean;
minRides?: number;
minCoasters?: number;
minSize?: number;
openingDateStart?: string;
openingDateEnd?: string;
}
// Park list response type
export type ParkListResponse = ApiResponse<Park[]>;
// Park suggestion response type
export interface ParkSuggestion {
id: string;
name: string;
slug: string;
status: ParkStatus;
owner?: {
name: string;
slug: string;
};
}
export type ParkSuggestionResponse = ApiResponse<ParkSuggestion[]>;

View File

@@ -0,0 +1,18 @@
import type { Config } from "tailwindcss";
export default {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: "var(--background)",
foreground: "var(--foreground)",
},
},
},
plugins: [],
} satisfies Config;

27
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -1,74 +1,127 @@
# Active Development Context
# Active Context
## Recently Completed
## Current Status (Updated 2/23/2025 3:41 PM)
### Park Search Implementation (2024-02-22)
### API Test Results
✅ GET /api/parks
- Returns paginated list of parks
- Includes relationships (areas, reviews, photos)
- Proper metadata with total count
- Type-safe response structure
1. Autocomplete Base:
- Created BaseAutocomplete in core/forms.py
- Configured project-wide auth requirement
- Added test coverage for base functionality
✅ Search Parameters
- ?search=universal returns matching parks
- ?page and ?limit for pagination
- Case-insensitive search
2. Park Search:
- Implemented ParkAutocomplete class
- Created ParkSearchForm with autocomplete widget
- Updated views and templates for integration
- Added comprehensive test suite
✅ POST /api/parks
- Correctly enforces authentication
- Returns 401 for unauthorized requests
- Validates required fields
3. Documentation:
- Updated memory-bank/features/parks/search.md
- Added test documentation
- Created user interface guidelines
❌ Park Detail Routes
- /parks/[slug] returns 404
- Need to implement park detail API
- Need to create park detail page
## Active Tasks
### Working Features
1. Parks API
- GET /api/parks with full data
- Search and pagination
- Protected POST endpoint
- Error handling
1. Testing:
- [ ] Run the test suite with `uv run pytest parks/tests/`
- [ ] Monitor test coverage with pytest-cov
- [ ] Verify HTMX interactions work as expected
2. Parks Listing
- Displays all parks
- Responsive grid layout
- Status badge with colors
- Loading states
- Error handling
2. Performance Monitoring:
- [ ] Add database indexes if needed
- [ ] Monitor query performance
- [ ] Consider caching strategies
### Immediate Next Steps
3. User Experience:
- [ ] Get feedback on search responsiveness
- [ ] Monitor error rates
- [ ] Check accessibility compliance
1. Park Detail Implementation (High Priority)
- [x] Create /api/parks/[slug] endpoint
- [x] Define response schema in api.ts
- [x] Implement GET handler in route.ts
- [x] Add error handling for invalid slugs
- [x] Add park detail page component
- [x] Create parks/[slug]/page.tsx
- [x] Implement data fetching with loading state
- [x] Add error boundary handling
- [x] Handle loading states
- [x] Create loading.tsx skeleton
- [x] Implement suspense boundaries
- [ ] Add reviews section
- [ ] Create reviews component
- [ ] Add reviews API endpoint
## Next Steps
2. Authentication (High Priority)
- [ ] Implement JWT token management
- [ ] Set up JWT middleware
- [ ] Add token refresh handling
- [ ] Store tokens securely
- [ ] Add login/register forms
- [ ] Create form components with validation
- [ ] Add form submission handlers
- [ ] Implement success/error states
- [ ] Protected route middleware
- [ ] Set up middleware.ts checks
- [ ] Add authentication redirect logic
- [ ] Auth context provider
- [ ] Create auth state management
- [ ] Add context hooks for components
1. Enhancements:
- Add geographic search capabilities
- Implement result caching
- Add full-text search support
3. UI Improvements (Medium Priority)
- [ ] Add search input in UI
- [ ] Create reusable search component
- [ ] Implement debounced API calls
- [ ] Implement filter controls
- [ ] Add filter state management
- [ ] Create filter UI components
- [ ] Add proper loading skeletons
- [ ] Design consistent skeleton layouts
- [ ] Implement skeleton components
- [ ] Improve error messages
- [ ] Create error message component
- [ ] Add error status pages
2. Integration:
- Extend to other models (Rides, Areas)
- Add combined search functionality
- Improve filter integration
### Known Issues
1. No authentication system yet
2. Missing park detail views
3. No form validation
4. No image upload handling
5. No real-time updates
6. Static metadata (page size)
3. Testing:
- Add Playwright e2e tests
- Implement performance benchmarks
- Add accessibility tests
### Required Documentation
1. API Endpoints
- ✅ GET /api/parks
- ✅ POST /api/parks
- ❌ GET /api/parks/[slug]
- ❌ PUT /api/parks/[slug]
- ❌ DELETE /api/parks/[slug]
## Technical Debt
2. Component Documentation
- ❌ Parks list component
- ❌ Park card component
- ❌ Status badge component
- ❌ Loading states
None currently identified for the search implementation.
3. Authentication Flow
- ❌ JWT implementation
- ❌ Protected routes
- ❌ Auth context
- ❌ Login/Register forms
## Dependencies
- django-htmx-autocomplete
- pytest-django
- pytest-cov
## Configuration
- Next.js 15.1.7
- Prisma with PostGIS
- PostgreSQL database
- REST API patterns
## Notes
The implementation follows these principles:
- Authentication-first approach
- Performance optimization
- Accessibility compliance
- Test coverage
- Clean documentation
1. Authentication needed before implementing write operations
2. Consider caching for park data
3. Need to implement proper error logging
4. Consider rate limiting for API

View File

@@ -0,0 +1,146 @@
# Next.js Migration Plan
## Overview
This document outlines the strategy for migrating ThrillWiki from a Django monolith to a Next.js frontend with API Routes backend while maintaining all existing functionality and design.
## Current Architecture
- Django monolithic application
- Django templates with HTMX and Alpine.js
- Django views handling both API and page rendering
- Django ORM for database operations
- Custom analytics system
- File upload handling through Django
- Authentication through Django
## Target Architecture
- Next.js 14+ application using App Router
- React components replacing Django templates
- Next.js API Routes replacing Django views
- Prisma ORM replacing Django ORM
- JWT-based authentication system
- Maintain current DB schema
- API-first approach with type safety
- File uploads through Next.js API routes
## Component Mapping
Major sections requiring migration:
1. Parks System:
- Convert Django views to API routes
- Convert templates to React components
- Implement dynamic routing
- Maintain search functionality
2. User System:
- Implement JWT authentication
- Convert user management to API routes
- Migrate profile management
- Handle avatar uploads
3. Reviews System:
- Convert to API routes
- Implement real-time updates
- Maintain moderation features
4. Analytics:
- Convert to API routes
- Implement client-side tracking
- Maintain current metrics
## API Route Mapping
```typescript
// Example API route structure
/api
/auth
/login
/register
/profile
/parks
/[id]
/search
/nearby
/reviews
/[id]
/create
/moderate
/analytics
/track
/stats
```
## Migration Phases
### Phase 1: Setup & Infrastructure
1. Initialize Next.js project
2. Set up Prisma with existing schema
3. Configure TypeScript
4. Set up authentication system
5. Configure file upload handling
### Phase 2: Core Features
1. Parks system migration
2. User authentication
3. Basic CRUD operations
4. Search functionality
5. File uploads
### Phase 3: Advanced Features
1. Reviews system
2. Analytics
3. Moderation tools
4. Real-time features
5. Admin interfaces
### Phase 4: Testing & Polish
1. Comprehensive testing
2. Performance optimization
3. SEO implementation
4. Security audit
5. Documentation updates
## Dependencies
### Frontend
- next: ^14.0.0
- react: ^18.2.0
- react-dom: ^18.2.0
- @prisma/client
- @tanstack/react-query
- tailwindcss
- typescript
- zod (for validation)
- jwt-decode
- @headlessui/react
### Backend
- prisma
- jsonwebtoken
- bcryptjs
- multer (file uploads)
- sharp (image processing)
## Data Migration Strategy
1. Create Prisma schema matching Django models
2. Write migration scripts for data transfer
3. Validate data integrity
4. Implement rollback procedures
## Security Considerations
1. JWT token handling
2. CSRF protection
3. Rate limiting
4. File upload security
5. API route protection
## Performance Optimization
1. Implement ISR (Incremental Static Regeneration)
2. Optimize images and assets
3. Implement caching strategy
4. Code splitting
5. Bundle optimization
## Rollback Plan
1. Maintain dual systems during migration
2. Database backup strategy
3. Traffic routing plan
4. Monitoring and alerts

View File

@@ -0,0 +1,165 @@
# Frontend Structure Documentation
## Project Organization
```
frontend/
├── src/
│ ├── app/ # Next.js App Router pages
│ ├── components/ # Reusable React components
│ ├── types/ # TypeScript type definitions
│ └── lib/ # Utility functions and configurations
```
## Component Architecture
### Core Components
1. **Layout (layout.tsx)**
- Global page structure
- Navigation header
- Footer
- Consistent styling across pages
- Using Inter font
- Responsive design with Tailwind
2. **Error Boundary (error-boundary.tsx)**
- Global error handling
- Fallback UI for errors
- Error reporting capabilities
- Retry functionality
- Type-safe implementation
3. **Home Page (page.tsx)**
- Parks listing
- Loading states
- Error handling
- Responsive grid layout
- Pagination support
## API Integration
### Parks API
- GET /api/parks
- Pagination support
- Search functionality
- Error handling
- Type-safe responses
## State Management
- Using React hooks for local state
- Server-side data fetching
- Error state handling
- Loading state management
## Styling Approach
1. **Tailwind CSS**
- Utility-first approach
- Custom theme configuration
- Responsive design utilities
- Component-specific styles
2. **Component Design**
- Consistent spacing
- Mobile-first approach
- Accessible color schemes
- Interactive states
## Type Safety
1. **TypeScript Integration**
- Strict type checking
- API response types
- Component props typing
- Error handling types
## Error Handling Strategy
1. **Multiple Layers**
- Component-level error boundaries
- API error handling
- Type-safe error responses
- User-friendly error messages
2. **Recovery Options**
- Retry functionality
- Graceful degradation
- Clear error messaging
- User guidance
## Performance Considerations
1. **Optimizations**
- Component code splitting
- Image optimization
- Responsive loading
- Caching strategy
2. **Monitoring**
- Error tracking
- Performance metrics
- User interactions
- API response times
## Accessibility
1. **Implementation**
- ARIA labels
- Keyboard navigation
- Focus management
- Screen reader support
## Next Steps
### Immediate Tasks
1. Implement authentication components
2. Add park detail page
3. Implement search functionality
4. Add pagination controls
### Future Improvements
1. Add park reviews system
2. Implement user profiles
3. Add social sharing
4. Enhance search capabilities
## Migration Progress
### Completed
✅ Basic page structure
✅ Error handling system
✅ Parks listing page
✅ API integration structure
### In Progress
⏳ Authentication system
⏳ Park details page
⏳ Search functionality
### Pending
⬜ User profiles
⬜ Reviews system
⬜ Admin interface
⬜ Analytics integration
## Testing Strategy
### Unit Tests
- Component rendering
- API integrations
- Error handling
- State management
### Integration Tests
- User flows
- API interactions
- Error scenarios
- Authentication
### E2E Tests
- Critical paths
- User journeys
- Cross-browser testing
- Mobile responsiveness
## Documentation Needs
1. Component API documentation
2. State management patterns
3. Error handling procedures
4. Testing guidelines

View File

@@ -0,0 +1,155 @@
# Next.js Migration Progress
## Current Status (Updated 2/23/2025)
### Completed Setup
1. ✅ Next.js project initialized in frontend/
2. ✅ TypeScript configuration
3. ✅ Prisma setup with PostGIS support
4. ✅ Environment configuration
5. ✅ API route structure
6. ✅ Initial database schema sync
7. ✅ Basic UI components
### Database Migration Status
- Detected existing Django schema with 70+ tables
- Successfully initialized Prisma with PostGIS extension
- Created initial migration
### Key Database Tables Identified
1. Core Tables
- accounts_user
- parks_park
- reviews_review
- location_location
- media_photo
2. Authentication Tables
- socialaccount_socialaccount
- token_blacklist_blacklistedtoken
- auth_permission
3. Content Management
- wiki_article
- wiki_articlerevision
- core_slughistory
### Implemented Features
1. Authentication Middleware
- Basic JWT token validation
- Public/private route handling
- Token forwarding
2. API Types System
- Base response types
- Park types
- User types
- Review types
- Error handling types
3. Database Connection
- Prisma client setup
- PostGIS extension configuration
- Development/production handling
4. Parks API Route
- GET endpoint with pagination
- Search functionality
- POST endpoint with auth
- Error handling
## Next Steps (Prioritized)
### 1. Schema Migration (Current Focus)
- [ ] Map remaining Django models to Prisma schema
- [ ] Handle custom field types (e.g., GeoDjango fields)
- [ ] Set up relationships between models
- [ ] Create data migration scripts
### 2. Authentication System
- [ ] Implement JWT verification
- [ ] Set up refresh tokens
- [ ] Social auth integration
- [ ] User session management
### 3. Core Features Migration
- [ ] Parks system
- [ ] User profiles
- [ ] Review system
- [ ] Media handling
### 4. Testing & Validation
- [ ] Unit tests for API routes
- [ ] Integration tests
- [ ] Data integrity checks
- [ ] Performance testing
## Technical Decisions
### Schema Migration Strategy
- Incremental model migration
- Maintain foreign key relationships
- Handle custom field types via Prisma
- Use PostGIS for spatial data
### Authentication Approach
- JWT for API authentication
- HTTP-only cookies for token storage
- Refresh token rotation
- Social auth provider integration
### API Architecture
- REST-based endpoints
- Strong type safety
- Consistent response formats
- Built-in pagination
- Error handling middleware
### Component Architecture
- Server components by default
- Client components for interactivity
- Shared component library
- Error boundaries
## Migration Challenges
### Current Challenges
1. Complex Django model relationships
2. Custom field type handling
3. Social authentication flow
4. File upload system
5. Real-time feature migration
### Solutions
1. Using Prisma's preview features for PostGIS
2. Custom field type mappings
3. JWT-based auth with refresh tokens
4. S3/cloud storage integration
5. WebSocket/Server-Sent Events
## Monitoring & Validation
### Data Integrity
- Validation scripts for migrated data
- Comparison tools for Django/Prisma models
- Automated testing of relationships
- Error logging and monitoring
### Performance
- API response time tracking
- Database query optimization
- Client-side performance metrics
- Error rate monitoring
## Documentation Updates
1. API route specifications
2. Schema migration process
3. Authentication flows
4. Component documentation
5. Deployment guides
## Rollback Strategy
1. Maintain Django application
2. Database backups before migrations
3. Feature flags for gradual rollout
4. Monitoring thresholds for auto-rollback

View File

@@ -0,0 +1,25 @@
# Park Detail API Implementation
## Overview
Implementing the park detail API endpoint at `/api/parks/[slug]` to provide detailed information about a specific park.
## Implementation Details
- Route: `/api/parks/[slug]/route.ts`
- Response Type: `ParkDetailResponse` (already defined in api.ts)
- Error Handling:
- 404 for invalid park slugs
- 500 for server/database errors
## Data Structure
Reusing existing Park type with full relationships:
- Basic park info (name, description, etc.)
- Areas
- Reviews with user info
- Photos with user info
- Creator details
- Owner (company) details
## Date Formatting
Following established pattern:
- Split ISO dates at 'T' for date-only fields
- Full ISO string for timestamps

View File

@@ -0,0 +1,44 @@
# Park Detail Page Implementation
## Overview
Implemented the park detail page component with features for displaying comprehensive park information.
## Components
1. `/parks/[slug]/page.tsx`
- Dynamic metadata generation
- Server-side data fetching with caching
- Complete park information display
- Responsive layout with Tailwind CSS
- Error handling with notFound()
2. `/parks/[slug]/loading.tsx`
- Skeleton loading state
- Matches final layout structure
- Animated pulse effect
- Responsive grid matching page layout
## Features
- Park header with name and status badge
- Description section
- Key details grid (size, rides, ratings)
- Location display
- Areas list with descriptions
- Reviews section with ratings
- Photo gallery
- Dynamic metadata
- Error handling
- Loading states
## Data Handling
- 60-second cache for data fetching
- Error states for 404 and other failures
- Proper type safety with ParkDetailResponse
- Formatted dates for consistency
## Design Patterns
- Semantic HTML structure
- Consistent spacing and typography
- Responsive grid layouts
- Color-coded status badges
- Progressive loading with suspense
- Modular section organization

View File

@@ -0,0 +1,128 @@
# Parks Page Next.js Implementation
## Troubleshooting Database Issues
### Database Setup and Maintenance
1. Created Database Reset Script
- Location: `frontend/src/scripts/db-reset.ts`
- Purpose: Clean database reset and reseed
- Features:
- Drops tables in correct order
- Runs seed script automatically
- Handles errors gracefully
- Usage: `npm run db:reset`
2. Package.json Scripts
```json
{
"scripts": {
"db:reset": "ts-node src/scripts/db-reset.ts",
"db:seed": "ts-node prisma/seed.ts",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate deploy"
}
}
```
3. Database Validation Steps
- Added connection test in API endpoint
- Added table existence check
- Enhanced error logging
- Added Prisma Client event listeners
### API Endpoint Improvements
1. Error Handling
```typescript
// Raw query test to verify database connection
const rawResult = await prisma.$queryRaw`SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname = 'public'`;
// Transaction usage for atomicity
const queryResult = await prisma.$transaction(async (tx) => {
const totalCount = await tx.park.count();
const parks = await tx.park.findMany({...});
return { totalCount, parks };
});
```
2. Simplified Query Structure
- Reduced complexity for debugging
- Added basic fields first
- Added proper type checking
- Enhanced error details
3. Debug Logging
- Added connection test logs
- Added query execution logs
- Enhanced error object logging
### Test Data Management
1. Seed Data Structure
- 2 users (admin and test user)
- 2 companies (Universal and Cedar Fair)
- 2 parks with full details
- Test reviews for each park
2. Data Types
- Location stored as JSON
- Dates properly formatted
- Numeric fields with correct precision
- Relationships properly established
### Current Status
✅ Completed:
- Database reset script
- Enhanced error handling
- Debug logging
- Test data setup
- API endpoint improvements
🚧 Next Steps:
1. Run database reset and verify data
2. Test API endpoint with fresh data
3. Verify frontend component rendering
4. Add error boundaries for component-level errors
### Debugging Commands
```bash
# Reset and reseed database
npm run db:reset
# Generate Prisma client
npm run prisma:generate
# Deploy migrations
npm run prisma:migrate
```
### API Endpoint Response Format
```typescript
{
success: boolean;
data?: Park[];
meta?: {
total: number;
};
error?: string;
}
```
## Technical Decisions
1. Using transactions for queries to ensure data consistency
2. Added raw query test to validate database connection
3. Enhanced error handling with specific error types
4. Added debug logging for development troubleshooting
5. Simplified query structure for easier debugging
## Next Actions
1. Run `npm run db:reset` to clean and reseed database
2. Test simplified API endpoint
3. Gradually add back filters once basic query works
4. Add error boundaries to React components

View File

@@ -1,5 +1,5 @@
# Django and REST framework
Django==5.2.1
Django==5.1.6
djangorestframework==3.15.2
django-cors-headers==4.7.0