Compare commits

..

3 Commits

83 changed files with 893 additions and 10984 deletions

View File

@@ -27,4 +27,17 @@ This applies to all management commands including but not limited to:
- Creating superuser: `uv run manage.py createsuperuser`
- Starting shell: `uv run manage.py shell`
NEVER use `python manage.py` or `uv run python manage.py`. Always use `uv run manage.py` directly.
NEVER use `python manage.py` or `uv run python manage.py`. Always use `uv run manage.py` directly.
## Static Files Management
IMPORTANT: All static files must be placed in the `static/` directory, not `staticfiles/`. The `staticfiles/` directory is reserved for Django's collectstatic command output and should not be used directly.
This consolidation:
1. Follows Django best practices of separating source static files from collected files
2. Prevents confusion between development and production static file locations
3. Makes it clear which static files are part of the source code (static/) versus compiled/collected (staticfiles/)
When adding new static files:
- Add them to `static/` directory
- Use Django's `static` template tag to reference them
- Run `uv run manage.py collectstatic` when deploying

49
autocomplete/__init__.py Normal file
View File

@@ -0,0 +1,49 @@
default_app_config = 'autocomplete.apps.AutocompleteConfig'
from django.db import models
from django.core.exceptions import ImproperlyConfigured
from django.forms.widgets import Widget
from django.template.loader import render_to_string
class ModelAutocomplete:
"""Base class for model-based autocomplete."""
model = None # Model class to use for autocomplete
search_attrs = [] # List of model attributes to search
minimum_search_length = 2 # Minimum length of search string
max_results = 10 # Maximum number of results to return
def __init__(self):
if not self.model:
raise ImproperlyConfigured("ModelAutocomplete requires a model class")
if not self.search_attrs:
raise ImproperlyConfigured("ModelAutocomplete requires search_attrs")
def get_search_results(self, search):
"""Return search results for a given search string."""
raise NotImplementedError("Subclasses must implement get_search_results()")
def format_result(self, obj):
"""Format a single result object."""
raise NotImplementedError("Subclasses must implement format_result()")
class AutocompleteWidget(Widget):
"""Widget for autocomplete fields."""
template_name = 'autocomplete/widget.html'
def __init__(self, ac_class, attrs=None):
super().__init__(attrs)
if not issubclass(ac_class, ModelAutocomplete):
raise ImproperlyConfigured("ac_class must be a subclass of ModelAutocomplete")
self.ac_class = ac_class
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
# Add ac_name for URL resolution
context['ac_name'] = self.ac_class.__name__.lower()
return context
def render(self, name, value, attrs=None, renderer=None):
context = self.get_context(name, value, attrs)
return render_to_string(self.template_name, context)

25
autocomplete/apps.py Normal file
View File

@@ -0,0 +1,25 @@
from django.apps import AppConfig
class AutocompleteConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'autocomplete'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._registry = {}
def ready(self):
"""Register all autocomplete classes."""
from parks.forms import ParkAutocomplete
# Register autocomplete classes
self.register_autocomplete('park', ParkAutocomplete)
def register_autocomplete(self, name, ac_class):
"""Register an autocomplete class."""
self._registry[name] = ac_class
def get_autocomplete_class(self, name):
"""Get an autocomplete class by name."""
return self._registry.get(name)

View File

@@ -0,0 +1,20 @@
{% if results %}
<ul class="py-1 overflow-auto max-h-60" role="listbox">
{% for result in results %}
<li class="px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer"
role="option"
@click="selectedId = '{{ result.key }}'; query = '{{ result.label }}'; $refs.filterForm.requestSubmit()">
<div class="flex flex-col">
<span class="font-medium">{{ result.label }}</span>
{% if result.extra %}
<span class="text-sm text-gray-500 dark:text-gray-400">{{ result.extra }}</span>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
{% else %}
<div class="px-4 py-2 text-gray-500 dark:text-gray-400">
No results found
</div>
{% endif %}

View File

@@ -0,0 +1,38 @@
{% load static %}
<div class="relative" x-data="{ query: '', selectedId: null }">
<input type="text"
name="{{ widget.name }}_search"
placeholder="{{ widget.attrs.placeholder|default:'Search...' }}"
class="{{ widget.attrs.class }}"
x-model="query"
@keydown.escape="query = ''"
hx-get="{% url 'autocomplete:items' ac_name %}"
hx-trigger="input changed delay:300ms"
hx-target="#{{ widget.name }}-suggestions"
hx-indicator="#{{ widget.name }}-indicator">
<input type="hidden"
name="{{ widget.name }}"
x-model="selectedId">
<!-- Loading indicator -->
<div id="{{ widget.name }}-indicator"
class="htmx-indicator absolute right-3 top-1/2 -translate-y-1/2"
role="status"
aria-label="Loading search results">
<svg class="w-5 h-5 text-gray-400 animate-spin" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
<span class="sr-only">Searching...</span>
</div>
<!-- Suggestions dropdown -->
<div id="{{ widget.name }}-suggestions"
class="absolute z-50 mt-1 w-full bg-white dark:bg-gray-800 rounded-md shadow-lg"
role="listbox"
style="display: none;"
x-show="query.length > 0">
</div>
</div>

9
autocomplete/urls.py Normal file
View File

@@ -0,0 +1,9 @@
from django.urls import path
from . import views
app_name = 'autocomplete'
urlpatterns = [
path('<str:ac_name>/items/', views.items, name='items'),
path('<str:ac_name>/toggle/', views.toggle, name='toggle'),
]

52
autocomplete/views.py Normal file
View File

@@ -0,0 +1,52 @@
from django.http import JsonResponse, HttpResponse
from django.shortcuts import get_object_or_404, render
from django.apps import apps
from django.core.exceptions import ImproperlyConfigured
def items(request, ac_name):
"""Return autocomplete items for a given autocomplete class."""
try:
# Get the autocomplete class from the registry
ac_class = apps.get_app_config('autocomplete').get_autocomplete_class(ac_name)
if not ac_class:
raise ImproperlyConfigured(f"No autocomplete class found for {ac_name}")
# Create instance and get results
ac = ac_class()
search = request.GET.get('search', '')
# Check minimum search length
if len(search) < ac.minimum_search_length:
return HttpResponse('')
# Get and format results
results = ac.get_search_results(search)[:ac.max_results]
formatted_results = [ac.format_result(obj) for obj in results]
# Render suggestions template
return render(request, 'autocomplete/suggestions.html', {
'results': formatted_results
})
except Exception as e:
return HttpResponse(str(e), status=400)
def toggle(request, ac_name):
"""Toggle selection state for an autocomplete item."""
try:
# Get the autocomplete class from the registry
ac_class = apps.get_app_config('autocomplete').get_autocomplete_class(ac_name)
if not ac_class:
raise ImproperlyConfigured(f"No autocomplete class found for {ac_name}")
# Create instance and handle toggle
ac = ac_class()
item_id = request.POST.get('id')
if not item_id:
raise ValueError("No item ID provided")
# Get the object and format it
obj = get_object_or_404(ac.model, pk=item_id)
result = ac.format_result(obj)
return JsonResponse(result)
except Exception as e:
return JsonResponse({'error': str(e)}, status=400)

41
frontend/.gitignore vendored
View File

@@ -1,41 +0,0 @@
# 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

View File

@@ -1,36 +0,0 @@
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

@@ -1,16 +0,0 @@
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;

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,34 +0,0 @@
{
"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

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

View File

@@ -1,116 +0,0 @@
-- 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

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

View File

@@ -1,137 +0,0 @@
// 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
}

View File

@@ -1,148 +0,0 @@
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();
});

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 391 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 128 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 385 B

View File

@@ -1,103 +0,0 @@
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

@@ -1,88 +0,0 @@
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

@@ -1,50 +0,0 @@
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.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -1,21 +0,0 @@
@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

@@ -1,62 +0,0 @@
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>
);
}

View File

@@ -1,84 +0,0 @@
'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

@@ -1,99 +0,0 @@
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

@@ -1,194 +0,0 @@
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

@@ -1,150 +0,0 @@
'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

@@ -1,81 +0,0 @@
'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

@@ -1,55 +0,0 @@
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

@@ -1,182 +0,0 @@
'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

@@ -1,41 +0,0 @@
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

@@ -1,70 +0,0 @@
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

@@ -1,105 +0,0 @@
'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

@@ -1,48 +0,0 @@
'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

@@ -1,25 +0,0 @@
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

@@ -1,38 +0,0 @@
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();

View File

@@ -1,83 +0,0 @@
// 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

@@ -1,18 +0,0 @@
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;

View File

@@ -1,27 +0,0 @@
{
"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,127 +1,74 @@
# Active Context
# Active Development Context
## Current Status (Updated 2/23/2025 3:41 PM)
## Recently Completed
### 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
### Park Search Implementation (2024-02-22)
✅ Search Parameters
- ?search=universal returns matching parks
- ?page and ?limit for pagination
- Case-insensitive search
1. Autocomplete Base:
- Created BaseAutocomplete in core/forms.py
- Configured project-wide auth requirement
- Added test coverage for base functionality
✅ POST /api/parks
- Correctly enforces authentication
- Returns 401 for unauthorized requests
- Validates required fields
2. Park Search:
- Implemented ParkAutocomplete class
- Created ParkSearchForm with autocomplete widget
- Updated views and templates for integration
- Added comprehensive test suite
❌ Park Detail Routes
- /parks/[slug] returns 404
- Need to implement park detail API
- Need to create park detail page
3. Documentation:
- Updated memory-bank/features/parks/search.md
- Added test documentation
- Created user interface guidelines
### Working Features
1. Parks API
- GET /api/parks with full data
- Search and pagination
- Protected POST endpoint
- Error handling
## Active Tasks
2. Parks Listing
- Displays all parks
- Responsive grid layout
- Status badge with colors
- Loading states
- 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
### Immediate Next Steps
2. Performance Monitoring:
- [ ] Add database indexes if needed
- [ ] Monitor query performance
- [ ] Consider caching strategies
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
3. User Experience:
- [ ] Get feedback on search responsiveness
- [ ] Monitor error rates
- [ ] Check accessibility compliance
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
## Next Steps
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
1. Enhancements:
- Add geographic search capabilities
- Implement result caching
- Add full-text search support
### 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)
2. Integration:
- Extend to other models (Rides, Areas)
- Add combined search functionality
- Improve filter integration
### Required Documentation
1. API Endpoints
- ✅ GET /api/parks
- ✅ POST /api/parks
- ❌ GET /api/parks/[slug]
- ❌ PUT /api/parks/[slug]
- ❌ DELETE /api/parks/[slug]
3. Testing:
- Add Playwright e2e tests
- Implement performance benchmarks
- Add accessibility tests
2. Component Documentation
- ❌ Parks list component
- ❌ Park card component
- ❌ Status badge component
- ❌ Loading states
## Technical Debt
3. Authentication Flow
- ❌ JWT implementation
- ❌ Protected routes
- ❌ Auth context
- ❌ Login/Register forms
None currently identified for the search implementation.
## Configuration
- Next.js 15.1.7
- Prisma with PostGIS
- PostgreSQL database
- REST API patterns
## Dependencies
- django-htmx-autocomplete
- pytest-django
- pytest-cov
## Notes
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
The implementation follows these principles:
- Authentication-first approach
- Performance optimization
- Accessibility compliance
- Test coverage
- Clean documentation

View File

@@ -1,146 +0,0 @@
# 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,61 @@
# Search Duplication Fix
## Issue
The park search was showing duplicate results because:
1. There were two separate forms with the same ID ("filter-form")
2. Both forms were targeting the same element ("#park-results")
3. The search form and filter form were operating independently
## Solution
1. Created a custom autocomplete package to handle search functionality:
- ModelAutocomplete base class for model-based autocomplete
- AutocompleteWidget for rendering the search input
- Templates for widget and suggestions
- Views for handling search and selection
2. Updated ParkAutocomplete to use ModelAutocomplete:
```python
class ParkAutocomplete(ModelAutocomplete):
model = Park
search_attrs = ['name']
minimum_search_length = 2
max_results = 8
```
3. Combined search and filter functionality into a single form:
```html
<form id="filter-form"
x-ref="filterForm"
hx-get="{% url 'parks:park_list' %}"
hx-target="#park-results"
hx-push-url="true"
hx-trigger="submit"
class="mt-4">
<div class="mb-6">
{{ search_form }} <!-- AutocompleteWidget -->
</div>
{% include "search/components/filter_form.html" with filter=filter %}
</form>
```
4. Added proper URL routing for autocomplete:
```python
path("ac/", include((autocomplete_patterns, "autocomplete"), namespace="autocomplete"))
```
## Benefits
1. No more duplicate search requests
2. Cleaner template structure
3. Better user experience with a single search interface
4. Proper integration with django-htmx-autocomplete
5. Simplified view logic
6. Reusable autocomplete functionality for other models
## Technical Details
- Using django-htmx-autocomplete's AutocompleteWidget for search
- Single form submission handles both search and filtering
- HTMX handles the dynamic updates
- View mode selection preserved during search/filter operations
- Minimum search length of 2 characters
- Maximum of 8 search results
- Search results include park status and location

View File

@@ -1,165 +0,0 @@
# 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

@@ -1,155 +0,0 @@
# 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

@@ -1,25 +0,0 @@
# 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

@@ -1,44 +0,0 @@
# 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

@@ -1,128 +0,0 @@
# 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

@@ -21,6 +21,23 @@
- Implement component-based structure
- Follow progressive enhancement
### Static Files Organization
1. Directory Structure
- `static/` - Source static files (CSS, JS, images, etc.)
- `staticfiles/` - Collected files (generated by collectstatic)
2. File Management Rules
- Place all source static files in `static/` directory
- Never directly modify `staticfiles/` directory
- Use Django's `static` template tag for references
- Run collectstatic before deployment
3. Benefits of Separation
- Clear distinction between source and compiled files
- Prevents confusion in development vs production
- Follows Django best practices
- Simplifies deployment process
## Design Patterns
### Data Access

15
parks/autocomplete.py Normal file
View File

@@ -0,0 +1,15 @@
from autocomplete import ModelAutocomplete
from .models import Park
class ParkAutocomplete(ModelAutocomplete):
"""Autocomplete class for Park model."""
model = Park
search_attrs = ['name', 'city', 'state', 'country'] # Fields to search
minimum_search_length = 2 # Start searching after 2 characters
max_results = 8 # Limit to 8 suggestions
# Customize display text
no_result_text = "No parks found matching your search."
narrow_search_text = "Showing %(page_size)s of %(total)s parks. Try narrowing your search."
type_at_least_n_characters = "Type at least %(n)s characters to search parks"

View File

@@ -1,14 +1,13 @@
from django import forms
from decimal import Decimal, InvalidOperation, ROUND_DOWN
from autocomplete import AutocompleteWidget
from autocomplete import ModelAutocomplete, AutocompleteWidget
from core.forms import BaseAutocomplete
from .models import Park
from location.models import Location
from .querysets import get_base_park_queryset
class ParkAutocomplete(BaseAutocomplete):
class ParkAutocomplete(ModelAutocomplete):
"""Autocomplete for searching parks.
Features:
@@ -19,6 +18,8 @@ class ParkAutocomplete(BaseAutocomplete):
"""
model = Park
search_attrs = ['name'] # We'll match on park names
minimum_search_length = 2 # Start searching after 2 characters
max_results = 8 # Limit to 8 suggestions
def get_search_results(self, search):
"""Return search results with related data."""

View File

@@ -47,68 +47,23 @@
{% block filter_section %}
<div class="mb-6">
<div class="max-w-3xl mx-auto relative mb-8">
<div class="w-full relative"
x-data="{ query: '', selectedId: null }"
@search-selected.window="
query = $event.detail;
selectedId = $event.target.value;
$refs.filterForm.querySelector('input[name=search]').value = query;
$refs.filterForm.submit();
query = '';
">
<form hx-get="{% url 'parks:suggest_parks' %}"
hx-target="#search-results"
hx-trigger="input changed delay:300ms"
hx-indicator="#search-indicator"
x-ref="searchForm">
<div class="relative">
<input type="search"
name="search"
placeholder="Search parks..."
class="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="query !== ''"
x-model="query"
@keydown.escape="query = ''">
<!-- Loading indicator -->
<div id="search-indicator"
class="htmx-indicator absolute right-3 top-1/2 -translate-y-1/2"
role="status"
aria-label="Loading search results">
<svg class="w-5 h-5 text-gray-400 animate-spin" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
<span class="sr-only">Searching...</span>
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<form id="filter-form"
x-ref="filterForm"
hx-get="{% url 'parks:park_list' %}"
hx-target="#park-results"
hx-push-url="true"
hx-trigger="submit"
class="mt-4">
<div class="mb-6">
{{ search_form }}
</div>
</div>
</form>
<div id="search-results"
class="absolute z-50 mt-1 w-full bg-white dark:bg-gray-800 rounded-md shadow-lg"
role="listbox">
<!-- Search suggestions will be loaded here -->
{% include "search/components/filter_form.html" with filter=filter %}
</form>
</div>
</div>
</div>
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg font-medium leading-6 text-gray-900">Filters</h3>
<form id="filter-form"
x-ref="filterForm"
hx-get="{% url 'parks:park_list' %}"
hx-target="#park-results"
hx-push-url="true"
hx-trigger="change, submit"
class="mt-4">
<input type="hidden" name="search" value="{{ request.GET.search }}">
{% include "search/components/filter_form.html" with filter=filter %}
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,37 +1,21 @@
{% load filter_utils %}
{% if suggestions %}
<div id="search-suggestions-results"
class="w-full bg-white rounded-md shadow-lg border border-gray-200 max-h-96 overflow-y-auto"
x-show="open"
x-cloak
@keydown.escape.window="open = false"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="transform opacity-0 scale-95"
x-transition:enter-end="transform opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95">
{% for park in suggestions %}
{% with location=park.location.first %}
<button type="button"
class="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 cursor-pointer flex items-center justify-between gap-2 transition duration-150"
:class="{ 'bg-blue-50': focusedIndex === {{ forloop.counter0 }} }"
@mousedown.prevent="query = '{{ park.name }}'; $refs.search.value = '{{ park.name }}'"
@mousedown.prevent="query = '{{ park.name }}'; $dispatch('search-selected', '{{ park.name }}'); open = false;"
role="option"
:aria-selected="focusedIndex === {{ forloop.counter0 }}"
tabindex="-1"
x-effect="if(focusedIndex === {{ forloop.counter0 }}) $el.scrollIntoView({block: 'nearest'})"
aria-label="{{ park.name }}{% if location.city %} in {{ location.city }}{% endif %}{% if location.state %}, {{ location.state }}{% endif %}">
<div class="flex items-center gap-2">
<span class="font-medium" x-text="focusedIndex === {{ forloop.counter0 }} ? '▶ {{ park.name }}' : '{{ park.name }}'"></span>
<span class="text-gray-500">
{% if location.city %}{{ location.city }}, {% endif %}
{% if location.state %}{{ location.state }}{% endif %}
</span>
</div>
</button>
{% endwith %}
{% endfor %}
</div>
{% if results %}
<div class="py-1">
{% for result in results %}
<button class="w-full text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700"
@click="$dispatch('search-selected', '{{ result.name }}')"
value="{{ result.id }}"
role="option">
<div class="flex flex-col">
<span class="font-medium text-gray-900 dark:text-white">{{ result.name }}</span>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ result.status }}{% if result.location %} • {{ result.location }}{% endif %}
</span>
</div>
</button>
{% endfor %}
</div>
{% else %}
<div class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">
{% if query %}No parks found matching "{{ query }}"{% else %}Start typing to search parks{% endif %}
</div>
{% endif %}

View File

@@ -18,8 +18,6 @@ urlpatterns = [
# Areas and search endpoints for HTMX
path("areas/", views.get_park_areas, name="get_park_areas"),
path("suggest_parks/", views_search.suggest_parks, name="suggest_parks"),
path("search/", views.search_parks, name="search_parks"),
# Park detail and related views

View File

@@ -1,5 +1,4 @@
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.shortcuts import render
from django.http import HttpRequest, HttpResponse
from django.views.generic import TemplateView
from django.urls import reverse
@@ -13,6 +12,8 @@ class ParkSearchView(TemplateView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Initialize search form
context['search_form'] = ParkSearchForm(self.request.GET)
# Initialize filter with current querystring
@@ -20,35 +21,11 @@ class ParkSearchView(TemplateView):
filter_instance = ParkFilter(self.request.GET, queryset=queryset)
context['filter'] = filter_instance
# Apply search if park ID selected via autocomplete
park_id = self.request.GET.get('park')
if park_id:
queryset = filter_instance.qs.filter(id=park_id)
else:
queryset = filter_instance.qs
# Get filtered queryset
queryset = filter_instance.qs
# Handle view mode
context['view_mode'] = self.request.GET.get('view_mode', 'grid')
context['parks'] = queryset
return context
def suggest_parks(request: HttpRequest) -> JsonResponse:
"""Return park search suggestions as JSON."""
query = request.GET.get('search', '').strip()
if not query:
return JsonResponse({'results': []})
queryset = get_base_park_queryset()
filter_instance = ParkFilter({'search': query}, queryset=queryset)
parks = filter_instance.qs[:8] # Limit to 8 suggestions
results = [{
'id': str(park.pk),
'name': park.name,
'status': park.get_status_display(),
'location': park.formatted_location or '',
'url': reverse('parks:park_detail', kwargs={'slug': park.slug})
} for park in parks]
return JsonResponse({'results': results})
return context

View File

@@ -1,44 +1,37 @@
/* Alert Styles */
.alert {
@apply fixed z-50 px-4 py-3 transition-all duration-500 transform rounded-lg shadow-lg right-4 top-4;
animation: slideIn 0.5s ease-out forwards;
padding: 1rem;
margin-bottom: 1rem;
border-radius: 0.375rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
opacity: 1;
transition: opacity 0.3s ease-in-out;
cursor: pointer;
}
.alert-success {
@apply text-white bg-green-500;
background-color: #E8F5E9;
border: 1px solid #A5D6A7;
color: #2E7D32;
}
.alert-error {
@apply text-white bg-red-500;
}
.alert-info {
@apply text-white bg-blue-500;
background-color: #FFEBEE;
border: 1px solid #FFCDD2;
color: #C62828;
}
.alert-warning {
@apply text-white bg-yellow-500;
background-color: #FFF3E0;
border: 1px solid #FFCC80;
color: #EF6C00;
}
/* Animation keyframes */
@keyframes slideIn {
0% {
transform: translateX(100%);
opacity: 0;
}
100% {
transform: translateX(0);
opacity: 1;
}
.alert-info {
background-color: #E3F2FD;
border: 1px solid #90CAF9;
color: #1565C0;
}
@keyframes slideOut {
0% {
transform: translateX(0);
opacity: 1;
}
100% {
transform: translateX(100%);
opacity: 0;
}
.alert.fade-out {
opacity: 0;
}

View File

@@ -1,18 +1,21 @@
document.addEventListener('DOMContentLoaded', function() {
// Get all alert elements
document.addEventListener('DOMContentLoaded', () => {
const alerts = document.querySelectorAll('.alert');
// For each alert
alerts.forEach(alert => {
// After 5 seconds
// Auto-hide alerts after 5 seconds
setTimeout(() => {
// Add slideOut animation
alert.style.animation = 'slideOut 0.5s ease-out forwards';
// Remove the alert after animation completes
alert.classList.add('fade-out');
setTimeout(() => {
alert.remove();
}, 500);
}, 300); // Match CSS transition duration
}, 5000);
// Add click-to-dismiss functionality
alert.addEventListener('click', () => {
alert.classList.add('fade-out');
setTimeout(() => {
alert.remove();
}, 300);
});
});
});
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,81 +1,50 @@
function locationAutocomplete(field, filterParks = false) {
return {
query: '',
suggestions: [],
fetchSuggestions() {
let url;
const params = new URLSearchParams({
q: this.query,
filter_parks: filterParks
});
document.addEventListener('DOMContentLoaded', () => {
const countryInput = document.querySelector('[name="country"]');
const regionInput = document.querySelector('[name="region"]');
const cityInput = document.querySelector('[name="city"]');
switch (field) {
case 'country':
url = '/parks/ajax/countries/';
break;
case 'region':
url = '/parks/ajax/regions/';
// Add country parameter if we're fetching regions
const countryInput = document.getElementById(filterParks ? 'country' : 'id_country_name');
if (countryInput && countryInput.value) {
params.append('country', countryInput.value);
}
break;
case 'city':
url = '/parks/ajax/cities/';
// Add country and region parameters if we're fetching cities
const regionInput = document.getElementById(filterParks ? 'region' : 'id_region_name');
const cityCountryInput = document.getElementById(filterParks ? 'country' : 'id_country_name');
if (regionInput && regionInput.value && cityCountryInput && cityCountryInput.value) {
params.append('country', cityCountryInput.value);
params.append('region', regionInput.value);
}
break;
}
if (!countryInput || !regionInput || !cityInput) return;
if (url) {
fetch(`${url}?${params}`)
.then(response => response.json())
.then(data => {
this.suggestions = data;
});
}
},
selectSuggestion(suggestion) {
this.query = suggestion.name;
this.suggestions = [];
// If this is a form field (not filter), update hidden fields
if (!filterParks) {
const hiddenField = document.getElementById(`id_${field}`);
if (hiddenField) {
hiddenField.value = suggestion.id;
}
// Clear dependent fields when parent field changes
if (field === 'country') {
const regionInput = document.getElementById('id_region_name');
const cityInput = document.getElementById('id_city_name');
const regionHidden = document.getElementById('id_region');
const cityHidden = document.getElementById('id_city');
if (regionInput) regionInput.value = '';
if (cityInput) cityInput.value = '';
if (regionHidden) regionHidden.value = '';
if (cityHidden) cityHidden.value = '';
} else if (field === 'region') {
const cityInput = document.getElementById('id_city_name');
const cityHidden = document.getElementById('id_city');
if (cityInput) cityInput.value = '';
if (cityHidden) cityHidden.value = '';
}
}
// Trigger form submission for filters
if (filterParks) {
htmx.trigger('#park-filters', 'change');
}
// Update regions when country changes
countryInput.addEventListener('change', () => {
const country = countryInput.value;
if (country) {
updateRegions(country);
// Clear city when country changes
cityInput.innerHTML = '<option value="">Select a city</option>';
}
};
}
});
// Update cities when region changes
regionInput.addEventListener('change', () => {
const country = countryInput.value;
const region = regionInput.value;
if (country && region) {
updateCities(country, region);
}
});
function updateRegions(country) {
fetch(`/location/regions/?country=${encodeURIComponent(country)}`)
.then(response => response.json())
.then(data => {
regionInput.innerHTML = '<option value="">Select a region</option>';
data.regions.forEach(region => {
const option = new Option(region, region);
regionInput.add(option);
});
});
}
function updateCities(country, region) {
fetch(`/location/cities/?country=${encodeURIComponent(country)}&region=${encodeURIComponent(region)}`)
.then(response => response.json())
.then(data => {
cityInput.innerHTML = '<option value="">Select a city</option>';
data.cities.forEach(city => {
const option = new Option(city, city);
cityInput.add(option);
});
});
}
});

View File

@@ -1,141 +1,40 @@
// Theme handling
// Theme Toggle
document.addEventListener('DOMContentLoaded', () => {
const themeToggle = document.getElementById('theme-toggle');
const html = document.documentElement;
// Initialize toggle state based on current theme
if (themeToggle) {
themeToggle.checked = html.classList.contains('dark');
// Handle toggle changes
themeToggle.addEventListener('change', function() {
const isDark = this.checked;
html.classList.toggle('dark', isDark);
localStorage.setItem('theme', isDark ? 'dark' : 'light');
});
// Listen for system theme changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
const isDark = e.matches;
html.classList.toggle('dark', isDark);
themeToggle.checked = isDark;
}
});
}
});
const themeIcon = themeToggle.nextElementSibling.querySelector('i');
// Handle search form submission
document.addEventListener('submit', (e) => {
if (e.target.matches('form[action*="search"]')) {
const searchInput = e.target.querySelector('input[name="q"]');
if (!searchInput.value.trim()) {
e.preventDefault();
// Set initial icon
updateThemeIcon();
themeToggle.addEventListener('change', () => {
if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
} else {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
}
}
});
updateThemeIcon();
});
// Mobile menu toggle with transitions
document.addEventListener('DOMContentLoaded', () => {
function updateThemeIcon() {
const isDark = document.documentElement.classList.contains('dark');
themeIcon.classList.remove('fa-sun', 'fa-moon');
themeIcon.classList.add(isDark ? 'fa-sun' : 'fa-moon');
}
// Mobile Menu Toggle
const mobileMenuBtn = document.getElementById('mobileMenuBtn');
const mobileMenu = document.getElementById('mobileMenu');
const menuIcon = mobileMenuBtn.querySelector('i');
if (mobileMenuBtn && mobileMenu) {
let isMenuOpen = false;
mobileMenu.style.display = 'none';
let isMenuOpen = false;
const toggleMenu = () => {
isMenuOpen = !isMenuOpen;
mobileMenu.classList.toggle('show', isMenuOpen);
mobileMenuBtn.setAttribute('aria-expanded', isMenuOpen.toString());
// Update icon
const icon = mobileMenuBtn.querySelector('i');
icon.classList.remove(isMenuOpen ? 'fa-bars' : 'fa-times');
icon.classList.add(isMenuOpen ? 'fa-times' : 'fa-bars');
};
mobileMenuBtn.addEventListener('click', toggleMenu);
// Close menu when clicking outside
document.addEventListener('click', (e) => {
if (isMenuOpen && !mobileMenu.contains(e.target) && !mobileMenuBtn.contains(e.target)) {
toggleMenu();
}
});
// Close menu when pressing escape
document.addEventListener('keydown', (e) => {
if (isMenuOpen && e.key === 'Escape') {
toggleMenu();
}
});
// Handle viewport changes
const mediaQuery = window.matchMedia('(min-width: 1024px)');
mediaQuery.addEventListener('change', (e) => {
if (e.matches && isMenuOpen) {
toggleMenu();
}
});
}
});
// User dropdown toggle
const userMenuBtn = document.getElementById('userMenuBtn');
const userDropdown = document.getElementById('userDropdown');
if (userMenuBtn && userDropdown) {
userMenuBtn.addEventListener('click', (e) => {
e.stopPropagation();
userDropdown.classList.toggle('active');
mobileMenuBtn.addEventListener('click', () => {
isMenuOpen = !isMenuOpen;
mobileMenu.style.display = isMenuOpen ? 'block' : 'none';
menuIcon.classList.remove('fa-bars', 'fa-times');
menuIcon.classList.add(isMenuOpen ? 'fa-times' : 'fa-bars');
});
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!userMenuBtn.contains(e.target) && !userDropdown.contains(e.target)) {
userDropdown.classList.remove('active');
}
});
// Close dropdown when pressing escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
userDropdown.classList.remove('active');
}
});
}
// Handle flash messages
document.addEventListener('DOMContentLoaded', () => {
const alerts = document.querySelectorAll('.alert');
alerts.forEach(alert => {
setTimeout(() => {
alert.style.opacity = '0';
setTimeout(() => alert.remove(), 300);
}, 5000);
});
});
// Initialize tooltips
document.addEventListener('DOMContentLoaded', () => {
const tooltips = document.querySelectorAll('[data-tooltip]');
tooltips.forEach(tooltip => {
tooltip.addEventListener('mouseenter', (e) => {
const text = e.target.getAttribute('data-tooltip');
const tooltipEl = document.createElement('div');
tooltipEl.className = 'absolute z-50 px-2 py-1 text-sm text-white bg-gray-900 rounded tooltip';
tooltipEl.textContent = text;
document.body.appendChild(tooltipEl);
const rect = e.target.getBoundingClientRect();
tooltipEl.style.top = rect.bottom + 5 + 'px';
tooltipEl.style.left = rect.left + (rect.width - tooltipEl.offsetWidth) / 2 + 'px';
});
tooltip.addEventListener('mouseleave', () => {
const tooltips = document.querySelectorAll('.tooltip');
tooltips.forEach(t => t.remove());
});
});
});
});

View File

@@ -1,29 +0,0 @@
// Only declare parkMap if it doesn't exist
window.parkMap = window.parkMap || null;
function initParkMap(latitude, longitude, name) {
const mapContainer = document.getElementById('park-map');
// Only initialize if container exists and map hasn't been initialized
if (mapContainer && !window.parkMap) {
const width = mapContainer.offsetWidth;
mapContainer.style.height = width + 'px';
window.parkMap = L.map('park-map').setView([latitude, longitude], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(window.parkMap);
L.marker([latitude, longitude])
.addTo(window.parkMap)
.bindPopup(name);
// Update map size when window is resized
window.addEventListener('resize', function() {
const width = mapContainer.offsetWidth;
mapContainer.style.height = width + 'px';
window.parkMap.invalidateSize();
});
}
}

View File

@@ -1,91 +0,0 @@
document.addEventListener('alpine:init', () => {
Alpine.data('photoDisplay', ({ photos, contentType, objectId, csrfToken, uploadUrl }) => ({
photos,
fullscreenPhoto: null,
uploading: false,
uploadProgress: 0,
error: null,
showSuccess: false,
showFullscreen(photo) {
this.fullscreenPhoto = photo;
},
async handleFileSelect(event) {
const files = Array.from(event.target.files);
if (!files.length) {
return;
}
this.uploading = true;
this.uploadProgress = 0;
this.error = null;
this.showSuccess = false;
const totalFiles = files.length;
let completedFiles = 0;
for (const file of files) {
const formData = new FormData();
formData.append('image', file);
formData.append('app_label', contentType.split('.')[0]);
formData.append('model', contentType.split('.')[1]);
formData.append('object_id', objectId);
try {
const response = await fetch(uploadUrl, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
},
body: formData
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Upload failed');
}
const photo = await response.json();
this.photos.push(photo);
completedFiles++;
this.uploadProgress = (completedFiles / totalFiles) * 100;
} catch (err) {
this.error = err.message || 'Failed to upload photo. Please try again.';
console.error('Upload error:', err);
break;
}
}
this.uploading = false;
event.target.value = ''; // Reset file input
if (!this.error) {
this.showSuccess = true;
setTimeout(() => {
this.showSuccess = false;
}, 3000);
}
},
async sharePhoto(photo) {
if (navigator.share) {
try {
await navigator.share({
title: photo.caption || 'Shared photo',
url: photo.url
});
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Error sharing:', err);
}
}
} else {
// Fallback: copy URL to clipboard
navigator.clipboard.writeText(photo.url)
.then(() => alert('Photo URL copied to clipboard!'))
.catch(err => console.error('Error copying to clipboard:', err));
}
}
}));
});

View File

@@ -1,42 +0,0 @@
function parkSearch() {
return {
query: '',
results: [],
loading: false,
selectedId: null,
async search() {
if (!this.query.trim()) {
this.results = [];
return;
}
this.loading = true;
try {
const response = await fetch(`/parks/suggest_parks/?search=${encodeURIComponent(this.query)}`);
const data = await response.json();
this.results = data.results;
} catch (error) {
console.error('Search failed:', error);
this.results = [];
} finally {
this.loading = false;
}
},
clear() {
this.query = '';
this.results = [];
this.selectedId = null;
},
selectPark(park) {
this.query = park.name;
this.selectedId = park.id;
this.results = [];
// Trigger filter update
document.getElementById('park-filters').dispatchEvent(new Event('change'));
}
};
}

View File

@@ -1,44 +1,37 @@
/* Alert Styles */
.alert {
@apply fixed z-50 px-4 py-3 transition-all duration-500 transform rounded-lg shadow-lg right-4 top-4;
animation: slideIn 0.5s ease-out forwards;
padding: 1rem;
margin-bottom: 1rem;
border-radius: 0.375rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
opacity: 1;
transition: opacity 0.3s ease-in-out;
cursor: pointer;
}
.alert-success {
@apply text-white bg-green-500;
background-color: #E8F5E9;
border: 1px solid #A5D6A7;
color: #2E7D32;
}
.alert-error {
@apply text-white bg-red-500;
}
.alert-info {
@apply text-white bg-blue-500;
background-color: #FFEBEE;
border: 1px solid #FFCDD2;
color: #C62828;
}
.alert-warning {
@apply text-white bg-yellow-500;
background-color: #FFF3E0;
border: 1px solid #FFCC80;
color: #EF6C00;
}
/* Animation keyframes */
@keyframes slideIn {
0% {
transform: translateX(100%);
opacity: 0;
}
100% {
transform: translateX(0);
opacity: 1;
}
.alert-info {
background-color: #E3F2FD;
border: 1px solid #90CAF9;
color: #1565C0;
}
@keyframes slideOut {
0% {
transform: translateX(0);
opacity: 1;
}
100% {
transform: translateX(100%);
opacity: 0;
}
.alert.fade-out {
opacity: 0;
}

View File

@@ -2181,6 +2181,18 @@ select {
justify-content: center;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
.visible {
visibility: visible;
}
@@ -2457,6 +2469,10 @@ select {
display: none;
}
.h-10 {
height: 2.5rem;
}
.h-16 {
height: 4rem;
}
@@ -2485,6 +2501,10 @@ select {
height: 1.25rem;
}
.h-6 {
height: 1.5rem;
}
.h-8 {
height: 2rem;
}
@@ -2533,6 +2553,10 @@ select {
width: 1.25rem;
}
.w-6 {
width: 1.5rem;
}
.w-64 {
width: 16rem;
}
@@ -2646,6 +2670,16 @@ select {
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
@keyframes pulse {
50% {
opacity: .5;
}
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
@@ -3000,10 +3034,6 @@ select {
background-color: rgb(17 24 39 / var(--tw-bg-opacity));
}
.bg-gray-900\/80 {
background-color: rgb(17 24 39 / 0.8);
}
.bg-green-100 {
--tw-bg-opacity: 1;
background-color: rgb(220 252 231 / var(--tw-bg-opacity));
@@ -3244,6 +3274,10 @@ select {
padding-bottom: 1rem;
}
.pt-2 {
padding-top: 0.5rem;
}
.text-left {
text-align: left;
}
@@ -3335,6 +3369,11 @@ select {
color: rgb(37 99 235 / var(--tw-text-opacity));
}
.text-blue-700 {
--tw-text-opacity: 1;
color: rgb(29 78 216 / var(--tw-text-opacity));
}
.text-blue-800 {
--tw-text-opacity: 1;
color: rgb(30 64 175 / var(--tw-text-opacity));
@@ -3405,6 +3444,11 @@ select {
color: rgb(79 70 229 / var(--tw-text-opacity));
}
.text-red-100 {
--tw-text-opacity: 1;
color: rgb(254 226 226 / var(--tw-text-opacity));
}
.text-red-400 {
--tw-text-opacity: 1;
color: rgb(248 113 113 / var(--tw-text-opacity));
@@ -3507,6 +3551,11 @@ select {
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
.outline-none {
outline: 2px solid transparent;
outline-offset: 2px;
}
.ring-2 {
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
@@ -3522,6 +3571,19 @@ select {
--tw-ring-color: rgb(79 70 229 / 0.2);
}
.ring-offset-2 {
--tw-ring-offset-width: 2px;
}
.ring-offset-white {
--tw-ring-offset-color: #fff;
}
.blur {
--tw-blur: blur(8px);
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
}
.filter {
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
}
@@ -3796,6 +3858,11 @@ select {
border-color: rgb(59 130 246 / var(--tw-border-opacity));
}
.focus\:bg-gray-100:focus {
--tw-bg-opacity: 1;
background-color: rgb(243 244 246 / var(--tw-bg-opacity));
}
.focus\:underline:focus {
text-decoration-line: underline;
}
@@ -3824,6 +3891,10 @@ select {
--tw-ring-offset-width: 2px;
}
.active\:transform:active {
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.disabled\:opacity-50:disabled {
opacity: 0.5;
}
@@ -3930,6 +4001,10 @@ select {
background-color: rgb(185 28 28 / var(--tw-bg-opacity));
}
.dark\:bg-red-900\/40:is(.dark *) {
background-color: rgb(127 29 29 / 0.4);
}
.dark\:bg-yellow-200:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(254 240 138 / var(--tw-bg-opacity));
@@ -3968,6 +4043,11 @@ select {
--tw-gradient-to: #3b0764 var(--tw-gradient-to-position);
}
.dark\:text-blue-100:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(219 234 254 / var(--tw-text-opacity));
}
.dark\:text-blue-200:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(191 219 254 / var(--tw-text-opacity));
@@ -4190,6 +4270,11 @@ select {
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.dark\:focus\:bg-gray-700:focus:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(55 65 81 / var(--tw-bg-opacity));
}
@media (min-width: 640px) {
.sm\:col-span-3 {
grid-column: span 3 / span 3;
@@ -4297,10 +4382,26 @@ select {
grid-column: span 2 / span 2;
}
.md\:col-span-3 {
grid-column: span 3 / span 3;
}
.md\:mb-8 {
margin-bottom: 2rem;
}
.md\:block {
display: block;
}
.md\:grid {
display: grid;
}
.md\:hidden {
display: none;
}
.md\:h-\[140px\] {
height: 140px;
}

View File

@@ -1,18 +1,21 @@
document.addEventListener('DOMContentLoaded', function() {
// Get all alert elements
document.addEventListener('DOMContentLoaded', () => {
const alerts = document.querySelectorAll('.alert');
// For each alert
alerts.forEach(alert => {
// After 5 seconds
// Auto-hide alerts after 5 seconds
setTimeout(() => {
// Add slideOut animation
alert.style.animation = 'slideOut 0.5s ease-out forwards';
// Remove the alert after animation completes
alert.classList.add('fade-out');
setTimeout(() => {
alert.remove();
}, 500);
}, 300); // Match CSS transition duration
}, 5000);
// Add click-to-dismiss functionality
alert.addEventListener('click', () => {
alert.classList.add('fade-out');
setTimeout(() => {
alert.remove();
}, 300);
});
});
});
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,262 +0,0 @@
document.addEventListener('DOMContentLoaded', function() {
// Handle edit button clicks
document.querySelectorAll('[data-edit-button]').forEach(button => {
button.addEventListener('click', function() {
const contentId = this.dataset.contentId;
const contentType = this.dataset.contentType;
const editableFields = document.querySelectorAll(`[data-editable][data-content-id="${contentId}"]`);
// Toggle edit mode
editableFields.forEach(field => {
const currentValue = field.textContent.trim();
const fieldName = field.dataset.fieldName;
const fieldType = field.dataset.fieldType || 'text';
// Create input field
let input;
if (fieldType === 'textarea') {
input = document.createElement('textarea');
input.value = currentValue;
input.rows = 4;
} else if (fieldType === 'select') {
input = document.createElement('select');
// Get options from data attribute
const options = JSON.parse(field.dataset.options || '[]');
options.forEach(option => {
const optionEl = document.createElement('option');
optionEl.value = option.value;
optionEl.textContent = option.label;
optionEl.selected = option.value === currentValue;
input.appendChild(optionEl);
});
} else if (fieldType === 'date') {
input = document.createElement('input');
input.type = 'date';
input.value = currentValue;
} else if (fieldType === 'number') {
input = document.createElement('input');
input.type = 'number';
input.value = currentValue;
if (field.dataset.min) input.min = field.dataset.min;
if (field.dataset.max) input.max = field.dataset.max;
if (field.dataset.step) input.step = field.dataset.step;
} else {
input = document.createElement('input');
input.type = fieldType;
input.value = currentValue;
}
input.className = 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white';
input.dataset.originalValue = currentValue;
input.dataset.fieldName = fieldName;
// Replace content with input
field.textContent = '';
field.appendChild(input);
});
// Show save/cancel buttons
const actionButtons = document.createElement('div');
actionButtons.className = 'flex gap-2 mt-2';
actionButtons.innerHTML = `
<button class="px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600" data-save-button>
<i class="mr-2 fas fa-save"></i>Save Changes
</button>
<button class="px-4 py-2 text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:bg-gray-600 dark:text-gray-200 dark:hover:bg-gray-500" data-cancel-button>
<i class="mr-2 fas fa-times"></i>Cancel
</button>
${this.dataset.requireReason ? `
<div class="flex-grow">
<input type="text" class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Reason for changes (required)"
data-reason-input>
<input type="text" class="w-full mt-1 border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Source (optional)"
data-source-input>
</div>
` : ''}
`;
const container = editableFields[0].closest('.editable-container');
container.appendChild(actionButtons);
// Hide edit button while editing
this.style.display = 'none';
});
});
// Handle form submissions
document.querySelectorAll('form[data-submit-type]').forEach(form => {
form.addEventListener('submit', function(e) {
e.preventDefault();
const submitType = this.dataset.submitType;
const formData = new FormData(this);
const data = {};
formData.forEach((value, key) => {
data[key] = value;
});
// Get CSRF token from meta tag
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
// Submit form
fetch(this.action, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
submission_type: submitType,
...data
})
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
showNotification(data.message, 'success');
if (data.redirect_url) {
window.location.href = data.redirect_url;
}
} else {
showNotification(data.message, 'error');
}
})
.catch(error => {
showNotification('An error occurred while submitting the form.', 'error');
console.error('Error:', error);
});
});
});
// Handle save button clicks using event delegation
document.addEventListener('click', function(e) {
if (e.target.matches('[data-save-button]')) {
const container = e.target.closest('.editable-container');
const contentId = container.querySelector('[data-editable]').dataset.contentId;
const contentType = container.querySelector('[data-edit-button]').dataset.contentType;
const editableFields = container.querySelectorAll('[data-editable]');
// Collect changes
const changes = {};
editableFields.forEach(field => {
const input = field.querySelector('input, textarea, select');
if (input && input.value !== input.dataset.originalValue) {
changes[input.dataset.fieldName] = input.value;
}
});
// If no changes, just cancel
if (Object.keys(changes).length === 0) {
cancelEdit(container);
return;
}
// Get reason and source if required
const reasonInput = container.querySelector('[data-reason-input]');
const sourceInput = container.querySelector('[data-source-input]');
const reason = reasonInput ? reasonInput.value : '';
const source = sourceInput ? sourceInput.value : '';
// Validate reason if required
if (reasonInput && !reason) {
alert('Please provide a reason for your changes.');
return;
}
// Get CSRF token from meta tag
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
// Submit changes
fetch(window.location.pathname, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
content_type: contentType,
content_id: contentId,
changes,
reason,
source
})
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
if (data.auto_approved) {
// Update the display immediately
Object.entries(changes).forEach(([field, value]) => {
const element = container.querySelector(`[data-editable][data-field-name="${field}"]`);
if (element) {
element.textContent = value;
}
});
}
showNotification(data.message, 'success');
if (data.redirect_url) {
window.location.href = data.redirect_url;
}
} else {
showNotification(data.message, 'error');
}
cancelEdit(container);
})
.catch(error => {
showNotification('An error occurred while saving changes.', 'error');
console.error('Error:', error);
cancelEdit(container);
});
}
});
// Handle cancel button clicks using event delegation
document.addEventListener('click', function(e) {
if (e.target.matches('[data-cancel-button]')) {
const container = e.target.closest('.editable-container');
cancelEdit(container);
}
});
});
function cancelEdit(container) {
// Restore original content
container.querySelectorAll('[data-editable]').forEach(field => {
const input = field.querySelector('input, textarea, select');
if (input) {
field.textContent = input.dataset.originalValue;
}
});
// Remove action buttons
const actionButtons = container.querySelector('.flex.gap-2');
if (actionButtons) {
actionButtons.remove();
}
// Show edit button
const editButton = container.querySelector('[data-edit-button]');
if (editButton) {
editButton.style.display = '';
}
}
function showNotification(message, type = 'info') {
const notification = document.createElement('div');
notification.className = `fixed bottom-4 right-4 p-4 rounded-lg shadow-lg text-white ${
type === 'success' ? 'bg-green-600 dark:bg-green-500' :
type === 'error' ? 'bg-red-600 dark:bg-red-500' :
'bg-blue-600 dark:bg-blue-500'
}`;
notification.textContent = message;
document.body.appendChild(notification);
// Remove after 5 seconds
setTimeout(() => {
notification.remove();
}, 5000);
}

View File

@@ -1,81 +0,0 @@
function locationAutocomplete(field, filterParks = false) {
return {
query: '',
suggestions: [],
fetchSuggestions() {
let url;
const params = new URLSearchParams({
q: this.query,
filter_parks: filterParks
});
switch (field) {
case 'country':
url = '/parks/ajax/countries/';
break;
case 'region':
url = '/parks/ajax/regions/';
// Add country parameter if we're fetching regions
const countryInput = document.getElementById(filterParks ? 'country' : 'id_country_name');
if (countryInput && countryInput.value) {
params.append('country', countryInput.value);
}
break;
case 'city':
url = '/parks/ajax/cities/';
// Add country and region parameters if we're fetching cities
const regionInput = document.getElementById(filterParks ? 'region' : 'id_region_name');
const cityCountryInput = document.getElementById(filterParks ? 'country' : 'id_country_name');
if (regionInput && regionInput.value && cityCountryInput && cityCountryInput.value) {
params.append('country', cityCountryInput.value);
params.append('region', regionInput.value);
}
break;
}
if (url) {
fetch(`${url}?${params}`)
.then(response => response.json())
.then(data => {
this.suggestions = data;
});
}
},
selectSuggestion(suggestion) {
this.query = suggestion.name;
this.suggestions = [];
// If this is a form field (not filter), update hidden fields
if (!filterParks) {
const hiddenField = document.getElementById(`id_${field}`);
if (hiddenField) {
hiddenField.value = suggestion.id;
}
// Clear dependent fields when parent field changes
if (field === 'country') {
const regionInput = document.getElementById('id_region_name');
const cityInput = document.getElementById('id_city_name');
const regionHidden = document.getElementById('id_region');
const cityHidden = document.getElementById('id_city');
if (regionInput) regionInput.value = '';
if (cityInput) cityInput.value = '';
if (regionHidden) regionHidden.value = '';
if (cityHidden) cityHidden.value = '';
} else if (field === 'region') {
const cityInput = document.getElementById('id_city_name');
const cityHidden = document.getElementById('id_city');
if (cityInput) cityInput.value = '';
if (cityHidden) cityHidden.value = '';
}
}
// Trigger form submission for filters
if (filterParks) {
htmx.trigger('#park-filters', 'change');
}
}
};
}

View File

@@ -1,79 +1,40 @@
// Initialize dark mode from localStorage
// Theme Toggle
document.addEventListener('DOMContentLoaded', () => {
// Check if dark mode was previously enabled
const darkMode = localStorage.getItem('darkMode') === 'true';
if (darkMode) {
document.documentElement.classList.add('dark');
}
});
const themeToggle = document.getElementById('theme-toggle');
const themeIcon = themeToggle.nextElementSibling.querySelector('i');
// Handle search form submission
document.addEventListener('submit', (e) => {
if (e.target.matches('form[action*="search"]')) {
const searchInput = e.target.querySelector('input[name="q"]');
if (!searchInput.value.trim()) {
e.preventDefault();
// Set initial icon
updateThemeIcon();
themeToggle.addEventListener('change', () => {
if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
} else {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
}
updateThemeIcon();
});
function updateThemeIcon() {
const isDark = document.documentElement.classList.contains('dark');
themeIcon.classList.remove('fa-sun', 'fa-moon');
themeIcon.classList.add(isDark ? 'fa-sun' : 'fa-moon');
}
});
// Close mobile menu when clicking outside
document.addEventListener('click', (e) => {
const mobileMenu = document.querySelector('[x-show="mobileMenuOpen"]');
const menuButton = document.querySelector('[x-on\\:click="mobileMenuOpen = !mobileMenuOpen"]');
if (mobileMenu && menuButton && !mobileMenu.contains(e.target) && !menuButton.contains(e.target)) {
const alpineData = mobileMenu._x_dataStack && mobileMenu._x_dataStack[0];
if (alpineData && alpineData.mobileMenuOpen) {
alpineData.mobileMenuOpen = false;
}
}
});
// Mobile Menu Toggle
const mobileMenuBtn = document.getElementById('mobileMenuBtn');
const mobileMenu = document.getElementById('mobileMenu');
const menuIcon = mobileMenuBtn.querySelector('i');
// Handle flash messages
document.addEventListener('DOMContentLoaded', () => {
const alerts = document.querySelectorAll('.alert');
alerts.forEach(alert => {
setTimeout(() => {
alert.style.opacity = '0';
setTimeout(() => alert.remove(), 300);
}, 5000);
mobileMenu.style.display = 'none';
let isMenuOpen = false;
mobileMenuBtn.addEventListener('click', () => {
isMenuOpen = !isMenuOpen;
mobileMenu.style.display = isMenuOpen ? 'block' : 'none';
menuIcon.classList.remove('fa-bars', 'fa-times');
menuIcon.classList.add(isMenuOpen ? 'fa-times' : 'fa-bars');
});
});
// Initialize tooltips
document.addEventListener('DOMContentLoaded', () => {
const tooltips = document.querySelectorAll('[data-tooltip]');
tooltips.forEach(tooltip => {
tooltip.addEventListener('mouseenter', (e) => {
const text = e.target.getAttribute('data-tooltip');
const tooltipEl = document.createElement('div');
tooltipEl.className = 'tooltip bg-gray-900 text-white px-2 py-1 rounded text-sm absolute z-50';
tooltipEl.textContent = text;
document.body.appendChild(tooltipEl);
const rect = e.target.getBoundingClientRect();
tooltipEl.style.top = rect.bottom + 5 + 'px';
tooltipEl.style.left = rect.left + (rect.width - tooltipEl.offsetWidth) / 2 + 'px';
});
tooltip.addEventListener('mouseleave', () => {
const tooltips = document.querySelectorAll('.tooltip');
tooltips.forEach(t => t.remove());
});
});
});
// Handle dropdown menus
document.addEventListener('click', (e) => {
const dropdowns = document.querySelectorAll('[x-show]');
dropdowns.forEach(dropdown => {
if (!dropdown.contains(e.target) &&
!e.target.matches('[x-on\\:click*="open = !open"]')) {
const alpineData = dropdown._x_dataStack && dropdown._x_dataStack[0];
if (alpineData && alpineData.open) {
alpineData.open = false;
}
}
});
});
});

View File

@@ -1,28 +0,0 @@
let parkMap = null;
function initParkMap(latitude, longitude, name) {
const mapContainer = document.getElementById('park-map');
// Only initialize if container exists and map hasn't been initialized
if (mapContainer && !parkMap) {
const width = mapContainer.offsetWidth;
mapContainer.style.height = width + 'px';
parkMap = L.map('park-map').setView([latitude, longitude], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(parkMap);
L.marker([latitude, longitude])
.addTo(parkMap)
.bindPopup(name);
// Update map size when window is resized
window.addEventListener('resize', function() {
const width = mapContainer.offsetWidth;
mapContainer.style.height = width + 'px';
parkMap.invalidateSize();
});
}
}

View File

@@ -1,91 +0,0 @@
document.addEventListener('alpine:init', () => {
Alpine.data('photoDisplay', ({ photos, contentType, objectId, csrfToken, uploadUrl }) => ({
photos,
fullscreenPhoto: null,
uploading: false,
uploadProgress: 0,
error: null,
showSuccess: false,
showFullscreen(photo) {
this.fullscreenPhoto = photo;
},
async handleFileSelect(event) {
const files = Array.from(event.target.files);
if (!files.length) {
return;
}
this.uploading = true;
this.uploadProgress = 0;
this.error = null;
this.showSuccess = false;
const totalFiles = files.length;
let completedFiles = 0;
for (const file of files) {
const formData = new FormData();
formData.append('image', file);
formData.append('app_label', contentType.split('.')[0]);
formData.append('model', contentType.split('.')[1]);
formData.append('object_id', objectId);
try {
const response = await fetch(uploadUrl, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
},
body: formData
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Upload failed');
}
const photo = await response.json();
this.photos.push(photo);
completedFiles++;
this.uploadProgress = (completedFiles / totalFiles) * 100;
} catch (err) {
this.error = err.message || 'Failed to upload photo. Please try again.';
console.error('Upload error:', err);
break;
}
}
this.uploading = false;
event.target.value = ''; // Reset file input
if (!this.error) {
this.showSuccess = true;
setTimeout(() => {
this.showSuccess = false;
}, 3000);
}
},
async sharePhoto(photo) {
if (navigator.share) {
try {
await navigator.share({
title: photo.caption || 'Shared photo',
url: photo.url
});
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Error sharing:', err);
}
}
} else {
// Fallback: copy URL to clipboard
navigator.clipboard.writeText(photo.url)
.then(() => alert('Photo URL copied to clipboard!'))
.catch(err => console.error('Error copying to clipboard:', err));
}
}
}));
});

View File

@@ -0,0 +1,134 @@
/* Loading states */
.htmx-request .htmx-indicator {
opacity: 1;
}
.htmx-request.htmx-indicator {
opacity: 1;
}
.htmx-indicator {
opacity: 0;
transition: opacity 200ms ease-in-out;
}
/* Results container transitions */
#park-results {
transition: opacity 200ms ease-in-out;
}
.htmx-request #park-results {
opacity: 0.7;
}
.htmx-settling #park-results {
opacity: 1;
}
/* Grid/List transitions */
.park-card {
transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
background-color: white;
border-radius: 0.5rem;
border: 1px solid #e5e7eb;
}
/* Grid view styles */
.park-card[data-view-mode="grid"] {
display: flex;
flex-direction: column;
}
.park-card[data-view-mode="grid"]:hover {
transform: translateY(-2px);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
/* List view styles */
.park-card[data-view-mode="list"] {
display: flex;
gap: 1rem;
padding: 1rem;
}
.park-card[data-view-mode="list"]:hover {
background-color: #f9fafb;
}
/* Image containers */
.park-card .image-container {
position: relative;
overflow: hidden;
}
.park-card[data-view-mode="grid"] .image-container {
aspect-ratio: 16 / 9;
width: 100%;
}
.park-card[data-view-mode="list"] .image-container {
width: 6rem;
height: 6rem;
flex-shrink: 0;
}
/* Content */
.park-card .content {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0; /* Enables text truncation in flex child */
}
/* Status badges */
.park-card .status-badge {
transition: all 150ms ease-in-out;
}
.park-card:hover .status-badge {
transform: scale(1.05);
}
/* Images */
.park-card img {
transition: transform 200ms ease-in-out;
object-fit: cover;
width: 100%;
height: 100%;
}
.park-card:hover img {
transform: scale(1.05);
}
/* Placeholders for missing images */
.park-card .placeholder {
background: linear-gradient(110deg, #ececec 8%, #f5f5f5 18%, #ececec 33%);
background-size: 200% 100%;
animation: shimmer 1.5s linear infinite;
}
@keyframes shimmer {
to {
background-position: 200% center;
}
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.park-card {
background-color: #1f2937;
border-color: #374151;
}
.park-card[data-view-mode="list"]:hover {
background-color: #374151;
}
.park-card .text-gray-900 {
color: #f3f4f6;
}
.park-card .text-gray-500 {
color: #9ca3af;
}
.park-card .placeholder {
background: linear-gradient(110deg, #2d3748 8%, #374151 18%, #2d3748 33%);
}
.park-card[data-view-mode="list"]:hover {
background-color: #374151;
}
}

View File

@@ -0,0 +1,69 @@
// Handle view mode persistence across HTMX requests
document.addEventListener('htmx:configRequest', function(evt) {
// Preserve view mode
const parkResults = document.getElementById('park-results');
if (parkResults) {
const viewMode = parkResults.getAttribute('data-view-mode');
if (viewMode) {
evt.detail.parameters['view_mode'] = viewMode;
}
}
// Preserve search terms
const searchInput = document.getElementById('search');
if (searchInput && searchInput.value) {
evt.detail.parameters['search'] = searchInput.value;
}
});
// Handle loading states
document.addEventListener('htmx:beforeRequest', function(evt) {
const target = evt.detail.target;
if (target) {
target.classList.add('htmx-requesting');
}
});
document.addEventListener('htmx:afterRequest', function(evt) {
const target = evt.detail.target;
if (target) {
target.classList.remove('htmx-requesting');
}
});
// Handle history navigation
document.addEventListener('htmx:historyRestore', function(evt) {
const parkResults = document.getElementById('park-results');
if (parkResults && evt.detail.path) {
const url = new URL(evt.detail.path, window.location.origin);
const viewMode = url.searchParams.get('view_mode');
if (viewMode) {
parkResults.setAttribute('data-view-mode', viewMode);
}
}
});
// Initialize lazy loading for images
function initializeLazyLoading(container) {
if (!('IntersectionObserver' in window)) return;
const imageObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.removeAttribute('data-src');
imageObserver.unobserve(img);
}
});
});
container.querySelectorAll('img[data-src]').forEach(img => {
imageObserver.observe(img);
});
}
// Initialize lazy loading after HTMX content swaps
document.addEventListener('htmx:afterSwap', function(evt) {
initializeLazyLoading(evt.detail.target);
});

View File

@@ -31,7 +31,7 @@
<script src="https://unpkg.com/htmx.org@1.9.6"></script>
<!-- Alpine.js -->
<script defer src="{% static 'js/alpine.min.js' %}"></script>
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<!-- Location Autocomplete -->
<script src="{% static 'js/location-autocomplete.js' %}"></script>

View File

@@ -7,6 +7,7 @@ from accounts import views as accounts_views
from django.views.generic import TemplateView
from .views import HomeView, SearchView
from . import views
from autocomplete.urls import urlpatterns as autocomplete_patterns
import os
urlpatterns = [
@@ -58,6 +59,8 @@ urlpatterns = [
views***REMOVED***ironment_and_settings_view,
name="environment_and_settings",
),
# Autocomplete URLs
path("ac/", include((autocomplete_patterns, "autocomplete"), namespace="autocomplete")),
]
# Serve static files in development