Initialize frontend project with Next.js, Tailwind CSS, and essential configurations

This commit is contained in:
pacnpal
2025-02-23 16:01:56 -05:00
parent 401449201c
commit 730b165f9c
31 changed files with 8882 additions and 56 deletions

View File

@@ -0,0 +1,217 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import { ParkListResponse, Park } from '@/types/api';
export async function GET(
request: NextRequest
): Promise<NextResponse<ParkListResponse>> {
try {
// Get query parameters
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '10');
const search = searchParams.get('search') || '';
const status = searchParams.get('status') || undefined;
// Calculate pagination
const skip = (page - 1) * limit;
// Build where clause
const where = {
AND: [
search ? {
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
],
} : {},
status ? { status } : {},
],
};
// Ensure database connection is initialized
if (!prisma) {
throw new Error('Database connection not initialized');
}
// Fetch parks with relationships
const [parks, total] = await Promise.all([
prisma.park.findMany({
where,
skip,
take: limit,
orderBy: {
name: 'asc',
},
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,
},
},
},
},
},
}),
prisma.park.count({ where }),
]);
// Transform dates and format response
const formattedParks = parks.map(park => ({
...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: formattedParks,
metadata: {
page,
limit,
total,
},
});
} catch (error) {
console.error('Error fetching parks:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch parks',
},
{ status: 500 }
);
}
}
export async function POST(
request: NextRequest
): Promise<NextResponse<ParkListResponse>> {
try {
// Ensure user is authenticated
const userToken = request.headers.get('x-user-token');
if (!userToken) {
return NextResponse.json(
{
success: false,
error: 'Unauthorized',
},
{ status: 401 }
);
}
const data = await request.json();
// Validate required fields
if (!data.name) {
return NextResponse.json(
{
success: false,
error: 'Name is required',
},
{ status: 400 }
);
}
// Generate slug from name
const slug = data.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
// Ensure database connection is initialized
if (!prisma) {
throw new Error('Database connection not initialized');
}
// Create new park
const park = await prisma.park.create({
data: {
name: data.name,
slug,
description: data.description,
status: data.status || 'OPERATING',
location: data.location,
opening_date: data.opening_date ? new Date(data.opening_date) : null,
closing_date: data.closing_date ? new Date(data.closing_date) : null,
operating_season: data.operating_season,
size_acres: data.size_acres,
website: data.website,
creatorId: parseInt(data.creatorId),
ownerId: data.ownerId ? parseInt(data.ownerId) : null,
},
include: {
creator: {
select: {
id: true,
username: true,
email: true,
},
},
owner: true,
},
});
return NextResponse.json({
success: true,
data: {
...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(),
},
});
} catch (error) {
console.error('Error creating park:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to create park',
},
{ 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,146 @@
'use client';
import { useEffect, useState } from 'react';
import type { Park, ParkStatus } from '@/types/api';
export default function ParksPage() {
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();
}, []);
const getStatusColor = (status: ParkStatus): string => {
const colors = {
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 colors[status] || 'bg-gray-100 text-gray-500';
};
const formatRating = (rating: number | null | undefined): string => {
if (typeof rating !== 'number') return 'No ratings';
return `${rating.toFixed(1)}/5`;
};
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) {
return (
<div 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>
</div>
);
}
return (
<div className="min-h-screen p-8">
<div className="max-w-7xl mx-auto">
<h1 className="text-3xl font-bold mb-8">Theme 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"
>
<div className="flex justify-between items-start mb-4">
<h2 className="text-xl font-semibold">
<a
href={`/parks/${park.slug}`}
className="hover:text-indigo-600 transition-colors"
>
{park.name}
</a>
</h2>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(park.status)}`}>
{park.status.replace('_', ' ')}
</span>
</div>
{park.description && (
<p className="text-gray-600 mb-4">
{park.description.length > 150
? `${park.description.slice(0, 150)}...`
: park.description}
</p>
)}
<div className="space-y-2 text-sm text-gray-500">
{park.location && (
<p>
📍 {park.location.latitude}, {park.location.longitude}
</p>
)}
{park.opening_date && (
<p>🗓 Opened: {new Date(park.opening_date).toLocaleDateString()}</p>
)}
{park.operating_season && (
<p> Season: {park.operating_season}</p>
)}
{park.website && (
<p>
<a
href={park.website}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-600 hover:text-indigo-800"
>
Visit Website
</a>
</p>
)}
</div>
<div className="mt-4 pt-4 border-t border-gray-100">
<div className="flex justify-between text-sm text-gray-500">
<span>🎢 {park.ride_count || 0} rides</span>
<span> {formatRating(park.average_rating)}</span>
</div>
</div>
</div>
))}
</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,85 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { headers } from 'next/headers';
// Paths that don't require authentication
const PUBLIC_PATHS = [
'/api/auth/login',
'/api/auth/register',
'/api/parks',
'/api/parks/search',
];
// Function to check if path is public
const isPublicPath = (path: string) => {
return PUBLIC_PATHS.some(publicPath => {
if (publicPath.endsWith('*')) {
return path.startsWith(publicPath.slice(0, -1));
}
return path === publicPath;
});
};
export async function middleware(request: NextRequest) {
const path = request.nextUrl.pathname;
const isApiRoute = path.startsWith('/api/');
// Only apply middleware to API routes
if (!isApiRoute) {
return NextResponse.next();
}
// Allow public paths
if (isPublicPath(path)) {
return NextResponse.next();
}
// Check for auth token
const authHeader = request.headers.get('authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return NextResponse.json(
{ success: false, error: 'Unauthorized' },
{ status: 401 }
);
}
try {
// TODO: Implement token verification
// For now, just check if token exists
const token = authHeader.split(' ')[1];
if (!token) {
throw new Error('Invalid token');
}
// Add user info to request headers for API routes
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-user-token', token);
// Clone the request with modified headers
const response = NextResponse.next({
request: {
headers: requestHeaders,
},
});
return response;
} catch (error) {
return NextResponse.json(
{ success: false, error: 'Invalid token' },
{ status: 401 }
);
}
}
export const config = {
matcher: [
/*
* Match all API routes:
* - /api/auth/login
* - /api/parks
* - /api/reviews
* etc.
*/
'/api/:path*',
],
};

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

@@ -0,0 +1,148 @@
// API Response Types
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
metadata?: {
page?: number;
limit?: number;
total?: number;
};
}
// Auth Types
export interface UserAuth {
id: number;
email: string;
username: string;
token: string;
}
// Park Status Enum
export enum ParkStatus {
OPERATING = 'OPERATING',
CLOSED_TEMP = 'CLOSED_TEMP',
CLOSED_PERM = 'CLOSED_PERM',
UNDER_CONSTRUCTION = 'UNDER_CONSTRUCTION',
DEMOLISHED = 'DEMOLISHED',
RELOCATED = 'RELOCATED'
}
// Park Types
export interface Park {
id: number;
name: string;
slug: string;
description?: string;
status: ParkStatus;
location?: {
latitude: number;
longitude: number;
};
opening_date?: string;
closing_date?: string;
operating_season?: string;
size_acres?: number;
website?: string;
average_rating?: number;
ride_count?: number;
coaster_count?: number;
creator?: User;
creatorId?: number;
owner?: Company;
ownerId?: number;
areas: ParkArea[];
reviews: Review[];
photos: Photo[];
created_at: string;
updated_at: string;
}
// Park Area Types
export interface ParkArea {
id: number;
name: string;
slug: string;
description?: string;
opening_date?: string;
closing_date?: string;
parkId: number;
park: Park;
created_at: string;
updated_at: string;
}
// Company Types
export interface Company {
id: number;
name: string;
website?: string;
parks: Park[];
created_at: string;
updated_at: string;
}
// Review Types
export interface Review {
id: number;
content: string;
rating: number;
parkId: number;
userId: number;
photos: Photo[];
created_at: string;
updated_at: string;
park: Park;
user: User;
}
// Photo Types
export interface Photo {
id: number;
url: string;
caption?: string;
parkId?: number;
reviewId?: number;
userId: number;
created_at: string;
updated_at: string;
park?: Park;
review?: Review;
user: User;
}
// User Types
export interface User {
id: number;
email: string;
username: string;
dateJoined: string;
isActive: boolean;
isStaff: boolean;
isSuperuser: boolean;
lastLogin?: string;
createdParks: Park[];
reviews: Review[];
photos: Photo[];
}
// Response Types
export interface ParkListResponse extends ApiResponse<Park[]> {}
export interface ParkDetailResponse extends ApiResponse<Park> {}
export interface ParkAreaListResponse extends ApiResponse<ParkArea[]> {}
export interface ParkAreaDetailResponse extends ApiResponse<ParkArea> {}
export interface CompanyListResponse extends ApiResponse<Company[]> {}
export interface CompanyDetailResponse extends ApiResponse<Company> {}
export interface ReviewListResponse extends ApiResponse<Review[]> {}
export interface ReviewDetailResponse extends ApiResponse<Review> {}
export interface UserListResponse extends ApiResponse<User[]> {}
export interface UserDetailResponse extends ApiResponse<User> {}
export interface PhotoListResponse extends ApiResponse<Photo[]> {}
export interface PhotoDetailResponse extends ApiResponse<Photo> {}
// Error Types
export interface ApiError {
message: string;
code?: string;
details?: Record<string, unknown>;
}