mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 18:11:08 -05:00
Compare commits
1 Commits
nestjs
...
feature/dj
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2faf0368cf |
41
frontend/.gitignore
vendored
41
frontend/.gitignore
vendored
@@ -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
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
6898
frontend/package-lock.json
generated
6898
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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");
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
@@ -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">
|
||||
© {new Date().getFullYear()} ThrillWiki. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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*',
|
||||
]
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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[]>;
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -1,127 +1,133 @@
|
||||
# Active Context
|
||||
# Active Context - Wiki Migration & Integration
|
||||
|
||||
## Current Status (Updated 2/23/2025 3:41 PM)
|
||||
## Current Status
|
||||
Corrected implementation strategy to use wiki-only approach instead of dual-system.
|
||||
|
||||
### 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
|
||||
### Completed Components
|
||||
1. Wiki Plugin Structure
|
||||
- Models for park metadata
|
||||
- Forms for data input
|
||||
- Templates for display
|
||||
- URL configurations
|
||||
|
||||
✅ Search Parameters
|
||||
- ?search=universal returns matching parks
|
||||
- ?page and ?limit for pagination
|
||||
- Case-insensitive search
|
||||
2. Documentation
|
||||
- Technical specifications
|
||||
- Migration guide
|
||||
- Implementation decisions
|
||||
- User guide
|
||||
|
||||
✅ POST /api/parks
|
||||
- Correctly enforces authentication
|
||||
- Returns 401 for unauthorized requests
|
||||
- Validates required fields
|
||||
### Current Focus
|
||||
Migration to wiki-only system
|
||||
|
||||
❌ Park Detail Routes
|
||||
- /parks/[slug] returns 404
|
||||
- Need to implement park detail API
|
||||
- Need to create park detail page
|
||||
## Immediate Tasks
|
||||
|
||||
### Working Features
|
||||
1. Parks API
|
||||
- GET /api/parks with full data
|
||||
- Search and pagination
|
||||
- Protected POST endpoint
|
||||
- Error handling
|
||||
### 1. Data Migration
|
||||
- [x] Create migration script
|
||||
- [ ] Test migration in development
|
||||
- [ ] Backup production data
|
||||
- [ ] Execute migration
|
||||
- [ ] Verify data integrity
|
||||
|
||||
2. Parks Listing
|
||||
- Displays all parks
|
||||
- Responsive grid layout
|
||||
- Status badge with colors
|
||||
- Loading states
|
||||
- Error handling
|
||||
### 2. URL Structure
|
||||
- [x] Update URL configuration
|
||||
- [x] Add redirects from old URLs
|
||||
- [ ] Test all redirects
|
||||
- [ ] Monitor 404 errors
|
||||
|
||||
### Immediate Next Steps
|
||||
### 3. Template Cleanup
|
||||
- [x] Remove dual-system templates
|
||||
- [x] Update wiki templates
|
||||
- [ ] Remove legacy templates
|
||||
- [ ] Clean up static files
|
||||
|
||||
1. Park Detail Implementation (High Priority)
|
||||
- [x] Create /api/parks/[slug] endpoint
|
||||
- [x] Define response schema in api.ts
|
||||
- [x] Implement GET handler in route.ts
|
||||
- [x] Add error handling for invalid slugs
|
||||
- [x] Add park detail page component
|
||||
- [x] Create parks/[slug]/page.tsx
|
||||
- [x] Implement data fetching with loading state
|
||||
- [x] Add error boundary handling
|
||||
- [x] Handle loading states
|
||||
- [x] Create loading.tsx skeleton
|
||||
- [x] Implement suspense boundaries
|
||||
- [ ] Add reviews section
|
||||
- [ ] Create reviews component
|
||||
- [ ] Add reviews API endpoint
|
||||
## Next Steps
|
||||
|
||||
2. Authentication (High Priority)
|
||||
- [ ] Implement JWT token management
|
||||
- [ ] Set up JWT middleware
|
||||
- [ ] Add token refresh handling
|
||||
- [ ] Store tokens securely
|
||||
- [ ] Add login/register forms
|
||||
- [ ] Create form components with validation
|
||||
- [ ] Add form submission handlers
|
||||
- [ ] Implement success/error states
|
||||
- [ ] Protected route middleware
|
||||
- [ ] Set up middleware.ts checks
|
||||
- [ ] Add authentication redirect logic
|
||||
- [ ] Auth context provider
|
||||
- [ ] Create auth state management
|
||||
- [ ] Add context hooks for components
|
||||
### 1. Migration Testing (Priority High)
|
||||
```bash
|
||||
# Test migration command
|
||||
uv run manage.py migrate_to_wiki --dry-run
|
||||
```
|
||||
|
||||
3. UI Improvements (Medium Priority)
|
||||
- [ ] Add search input in UI
|
||||
- [ ] Create reusable search component
|
||||
- [ ] Implement debounced API calls
|
||||
- [ ] Implement filter controls
|
||||
- [ ] Add filter state management
|
||||
- [ ] Create filter UI components
|
||||
- [ ] Add proper loading skeletons
|
||||
- [ ] Design consistent skeleton layouts
|
||||
- [ ] Implement skeleton components
|
||||
- [ ] Improve error messages
|
||||
- [ ] Create error message component
|
||||
- [ ] Add error status pages
|
||||
### 2. Plugin Refinement
|
||||
- Add missing metadata fields
|
||||
- Optimize queries
|
||||
- Implement caching
|
||||
- Add validation
|
||||
|
||||
### Known Issues
|
||||
1. No authentication system yet
|
||||
2. Missing park detail views
|
||||
3. No form validation
|
||||
4. No image upload handling
|
||||
5. No real-time updates
|
||||
6. Static metadata (page size)
|
||||
### 3. User Experience
|
||||
- Update navigation
|
||||
- Add search integration
|
||||
- Improve metadata forms
|
||||
- Add quick actions
|
||||
|
||||
### Required Documentation
|
||||
1. API Endpoints
|
||||
- ✅ GET /api/parks
|
||||
- ✅ POST /api/parks
|
||||
- ❌ GET /api/parks/[slug]
|
||||
- ❌ PUT /api/parks/[slug]
|
||||
- ❌ DELETE /api/parks/[slug]
|
||||
## Technical Requirements
|
||||
|
||||
2. Component Documentation
|
||||
- ❌ Parks list component
|
||||
- ❌ Park card component
|
||||
- ❌ Status badge component
|
||||
- ❌ Loading states
|
||||
### Migration
|
||||
1. Database Backup
|
||||
```sql
|
||||
pg_dump thrillwiki > backup.sql
|
||||
```
|
||||
|
||||
3. Authentication Flow
|
||||
- ❌ JWT implementation
|
||||
- ❌ Protected routes
|
||||
- ❌ Auth context
|
||||
- ❌ Login/Register forms
|
||||
2. Data Verification
|
||||
```python
|
||||
# Verify counts match
|
||||
parks_count = Park.objects.count()
|
||||
wiki_count = Article.objects.filter(
|
||||
plugin_parks_parkmetadata__isnull=False
|
||||
).count()
|
||||
```
|
||||
|
||||
## Configuration
|
||||
- Next.js 15.1.7
|
||||
- Prisma with PostGIS
|
||||
- PostgreSQL database
|
||||
- REST API patterns
|
||||
3. Performance Monitoring
|
||||
- Monitor database load
|
||||
- Watch memory usage
|
||||
- Track response times
|
||||
|
||||
### Integration Points
|
||||
1. User Authentication
|
||||
- Wiki permissions
|
||||
- Role mapping
|
||||
- Access control
|
||||
|
||||
2. Media Handling
|
||||
- Image storage
|
||||
- File management
|
||||
- Gallery support
|
||||
|
||||
3. Search Integration
|
||||
- Index wiki content
|
||||
- Include metadata
|
||||
- Update search views
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
### Data Loss Prevention
|
||||
- Complete backup before migration
|
||||
- Dry run verification
|
||||
- Rollback plan prepared
|
||||
- Data integrity checks
|
||||
|
||||
### Performance Impact
|
||||
- Monitor database load
|
||||
- Cache aggressively
|
||||
- Optimize queries
|
||||
- Staged migration
|
||||
|
||||
### User Disruption
|
||||
- Clear communication
|
||||
- Maintenance window
|
||||
- Quick rollback option
|
||||
- Support documentation
|
||||
|
||||
## Success Criteria
|
||||
1. All park data migrated
|
||||
2. No data loss
|
||||
3. All features functional
|
||||
4. Performance maintained
|
||||
5. Users can access content
|
||||
6. Search working correctly
|
||||
|
||||
## 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
|
||||
- Keep old models temporarily
|
||||
- Monitor error logs
|
||||
- Document all issues
|
||||
- Track performance metrics
|
||||
147
memory-bank/decisions/django_wiki_evaluation.md
Normal file
147
memory-bank/decisions/django_wiki_evaluation.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# Django-Wiki Transformation Evaluation
|
||||
|
||||
## Current System State
|
||||
- Early stage project with minimal existing data
|
||||
- Complex custom implementation for content management
|
||||
- Multiple specialized apps that may be overkill for current needs
|
||||
- HTMX + AlpineJS + Tailwind CSS frontend
|
||||
|
||||
## Django-Wiki Analysis
|
||||
|
||||
### Core Features Provided
|
||||
1. Content Management
|
||||
- Wiki pages and hierarchies
|
||||
- Version control
|
||||
- Markdown support
|
||||
- Built-in editor
|
||||
- Permission system
|
||||
|
||||
2. Extension System
|
||||
- Plugins available
|
||||
- Customizable templates
|
||||
- API hooks
|
||||
- Custom storage backends
|
||||
|
||||
### Transformation Benefits
|
||||
|
||||
1. **Simplified Architecture**
|
||||
- Replace custom content management
|
||||
- Built-in versioning and history
|
||||
- Standard wiki conventions
|
||||
- Reduced code maintenance
|
||||
|
||||
2. **Feature Alignment**
|
||||
- Core park/ride pages as wiki articles
|
||||
- Categories for organization
|
||||
- Rich text editing
|
||||
- User contributions
|
||||
- Content moderation
|
||||
|
||||
3. **Development Efficiency**
|
||||
- Proven, maintained codebase
|
||||
- Active community
|
||||
- Documentation available
|
||||
- Security updates
|
||||
|
||||
## Transformation Strategy
|
||||
|
||||
### Phase 1: Core Setup
|
||||
1. Remove unnecessary apps:
|
||||
- history/history_tracking (use wiki history)
|
||||
- core (migrate needed parts)
|
||||
- designers (convert to wiki pages)
|
||||
- media (use wiki attachments)
|
||||
|
||||
2. Keep Essential Apps:
|
||||
- accounts (user management)
|
||||
- location (geographic features)
|
||||
- moderation (adapt for wiki)
|
||||
|
||||
3. Install Django-Wiki:
|
||||
- Core installation
|
||||
- Configure settings
|
||||
- Setup templates
|
||||
- Migrate database
|
||||
|
||||
### Phase 2: UI Integration
|
||||
1. Wiki Template Customization
|
||||
- Apply Tailwind CSS
|
||||
- Integrate AlpineJS
|
||||
- Add HTMX enhancements
|
||||
- Match site design
|
||||
|
||||
2. Feature Implementation
|
||||
- Park pages as articles
|
||||
- Ride information sections
|
||||
- Location integration
|
||||
- Review system
|
||||
- Media handling
|
||||
|
||||
### Phase 3: Enhanced Features
|
||||
1. Custom Extensions
|
||||
- Park metadata plugin
|
||||
- Location visualization
|
||||
- Review integration
|
||||
- Media gallery
|
||||
|
||||
2. User Experience
|
||||
- Navigation structure
|
||||
- Search optimization
|
||||
- Mobile responsiveness
|
||||
- Performance tuning
|
||||
|
||||
## Technical Requirements
|
||||
|
||||
### Core Dependencies
|
||||
- django-wiki
|
||||
- django-mptt (tree structure)
|
||||
- django-nyt (notifications)
|
||||
- Markdown processing
|
||||
- Pillow (images)
|
||||
- Sorl-thumbnail (thumbnails)
|
||||
|
||||
### Frontend Integration
|
||||
- Custom templates
|
||||
- Tailwind CSS setup
|
||||
- AlpineJS components
|
||||
- HTMX interactions
|
||||
|
||||
### Authentication
|
||||
- Retain current auth system
|
||||
- Integrate with wiki permissions
|
||||
- Role-based access
|
||||
- Moderation workflow
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
1. **Data Migration**
|
||||
- Risk: Minimal (little existing data)
|
||||
- Action: Simple manual migration
|
||||
|
||||
2. **Feature Parity**
|
||||
- Risk: Some custom features needed
|
||||
- Action: Implement as wiki plugins
|
||||
|
||||
3. **Performance**
|
||||
- Risk: Standard wiki performance
|
||||
- Action: Implement caching
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Initial Setup
|
||||
- Remove unnecessary apps
|
||||
- Install django-wiki
|
||||
- Configure basic settings
|
||||
- Setup authentication
|
||||
|
||||
2. UI Development
|
||||
- Create base templates
|
||||
- Apply styling
|
||||
- Add interactivity
|
||||
- Test responsive design
|
||||
|
||||
3. Custom Features
|
||||
- Develop needed plugins
|
||||
- Integrate location services
|
||||
- Setup moderation
|
||||
- Configure search
|
||||
@@ -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
|
||||
@@ -1,71 +0,0 @@
|
||||
# Park Search Implementation Improvements
|
||||
|
||||
## Context
|
||||
The park search functionality needed to be updated to follow consistent patterns across the application and strictly adhere to the "NO CUSTOM JS" rule. Previously, search functionality was inconsistent and did not fully utilize built-in framework features.
|
||||
|
||||
## Decision
|
||||
Implemented a unified search pattern that:
|
||||
1. Uses only built-in HTMX and Alpine.js features
|
||||
2. Matches location search pattern
|
||||
3. Removes any custom JavaScript files
|
||||
4. Maintains consistency across the application
|
||||
|
||||
### Benefits
|
||||
1. **Simplified Architecture:**
|
||||
- No custom JavaScript files needed
|
||||
- Direct template-based implementation
|
||||
- Reduced maintenance burden
|
||||
- Smaller codebase
|
||||
|
||||
2. **Framework Alignment:**
|
||||
- Uses HTMX for AJAX requests
|
||||
- Uses Alpine.js for state management
|
||||
- All functionality in templates
|
||||
- Follows project patterns
|
||||
|
||||
3. **Better Maintainability:**
|
||||
- Single source of truth in templates
|
||||
- Reduced complexity
|
||||
- Easier to understand
|
||||
- Consistent with other features
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Template Features
|
||||
1. HTMX Integration:
|
||||
- Debounced search requests (300ms)
|
||||
- Loading indicators
|
||||
- JSON response handling
|
||||
|
||||
2. Alpine.js Usage:
|
||||
- State management in template
|
||||
- Event handling
|
||||
- UI updates
|
||||
- Keyboard interactions
|
||||
|
||||
### Backend Changes
|
||||
1. JSON API:
|
||||
- Consistent response format
|
||||
- Type validation
|
||||
- Limited results (8 items)
|
||||
- Performance optimization
|
||||
|
||||
2. View Updates:
|
||||
- Search filtering
|
||||
- Result formatting
|
||||
- Error handling
|
||||
- State preservation
|
||||
|
||||
## Benefits
|
||||
1. Better adherence to project standards
|
||||
2. Simplified codebase
|
||||
3. Reduced technical debt
|
||||
4. Easier maintenance
|
||||
5. Consistent user experience
|
||||
|
||||
## Testing
|
||||
1. API response format
|
||||
2. Empty search handling
|
||||
3. Field validation
|
||||
4. UI interactions
|
||||
5. State management
|
||||
@@ -1,24 +0,0 @@
|
||||
# Search Form Fix
|
||||
|
||||
## Issue
|
||||
Search results were being duplicated because selecting a suggestion triggered both:
|
||||
1. The suggestions form submission (to /suggest_parks/)
|
||||
2. The filter form submission (to /park_list/)
|
||||
|
||||
## Root Cause
|
||||
The `@search-selected` event handler was submitting the wrong form. It was submitting the suggestions form which has `hx-target="#search-results"` instead of the filter form which has `hx-target="#park-results"`.
|
||||
|
||||
## Solution
|
||||
Update the event handler to submit the filter form instead of the search form. This ensures only one request is made to update the results.
|
||||
|
||||
## Implementation
|
||||
1. Modified the `@search-selected` handler to:
|
||||
- Set the search query in filter form
|
||||
- Submit filter form to update results
|
||||
- Hide suggestions dropdown
|
||||
2. Added proper form IDs and refs
|
||||
|
||||
## Benefits
|
||||
- Eliminates duplicate requests
|
||||
- Maintains correct search behavior
|
||||
- Improves user experience
|
||||
96
memory-bank/decisions/wiki_implementation_correction.md
Normal file
96
memory-bank/decisions/wiki_implementation_correction.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Wiki Implementation Correction
|
||||
|
||||
## Original Misunderstanding
|
||||
We incorrectly attempted to maintain both systems:
|
||||
- Traditional park/ride system
|
||||
- Wiki-based system
|
||||
|
||||
This was WRONG. The correct approach is to fully migrate to wiki-based system.
|
||||
|
||||
## Corrected Approach
|
||||
|
||||
### 1. Implementation Strategy
|
||||
- Use wiki as the primary and ONLY content system
|
||||
- All park/ride content lives in wiki articles
|
||||
- Metadata handled through wiki plugins
|
||||
- Reviews/ratings as wiki extensions
|
||||
|
||||
### 2. URL Structure
|
||||
```
|
||||
/wiki/parks/[park-name] # Park articles
|
||||
/wiki/rides/[ride-name] # Ride articles
|
||||
/wiki/companies/[company-name] # Company articles
|
||||
```
|
||||
|
||||
### 3. Data Migration Plan
|
||||
1. Convert existing parks to wiki articles
|
||||
2. Transfer metadata to wiki plugin system
|
||||
3. Move existing reviews to wiki comment system
|
||||
4. Redirect old URLs to wiki system
|
||||
|
||||
### 4. Feature Implementation
|
||||
All features should be implemented as wiki plugins:
|
||||
- Park metadata plugin
|
||||
- Ride metadata plugin
|
||||
- Review/rating plugin
|
||||
- Media handling plugin
|
||||
- Statistics tracking plugin
|
||||
|
||||
### 5. Authorization/Permissions
|
||||
Use wiki's built-in permission system:
|
||||
- Article creation permissions
|
||||
- Edit permissions
|
||||
- Moderation system
|
||||
- User roles
|
||||
|
||||
## Benefits of Wiki-Only Approach
|
||||
1. Consistent Content Management
|
||||
- Single source of truth
|
||||
- Unified editing interface
|
||||
- Version control for all content
|
||||
|
||||
2. Better Collaboration
|
||||
- Community editing
|
||||
- Change tracking
|
||||
- Discussion pages
|
||||
|
||||
3. Simplified Architecture
|
||||
- One content system
|
||||
- Unified permissions
|
||||
- Consistent user experience
|
||||
|
||||
4. Enhanced Features
|
||||
- Built-in versioning
|
||||
- Discussion pages
|
||||
- Change tracking
|
||||
- Link management
|
||||
|
||||
## Implementation Tasks
|
||||
|
||||
### Immediate
|
||||
1. Remove dual-system templates
|
||||
2. Create wiki-only templates
|
||||
3. Set up plugin architecture
|
||||
|
||||
### Short Term
|
||||
1. Create data migration scripts
|
||||
2. Update URL routing
|
||||
3. Implement wiki plugins
|
||||
|
||||
### Long Term
|
||||
1. Phase out old models
|
||||
2. Remove legacy code
|
||||
3. Update documentation
|
||||
|
||||
## Migration Strategy
|
||||
1. Create wiki articles for all parks
|
||||
2. Migrate metadata to plugins
|
||||
3. Move media to wiki system
|
||||
4. Update all references
|
||||
5. Remove old system
|
||||
|
||||
## Documentation Updates Needed
|
||||
1. Update user guides
|
||||
2. Create wiki contribution guides
|
||||
3. Document plugin usage
|
||||
4. Update API documentation
|
||||
187
memory-bank/decisions/wiki_plugin_decisions.md
Normal file
187
memory-bank/decisions/wiki_plugin_decisions.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# Wiki Plugin Implementation Decisions
|
||||
|
||||
## Parks Plugin Design Decisions
|
||||
|
||||
### 1. Plugin Architecture
|
||||
**Decision:** Implement as full Django-Wiki plugin rather than standalone app
|
||||
**Rationale:**
|
||||
- Better integration with wiki features
|
||||
- Consistent user experience
|
||||
- Built-in revision tracking
|
||||
- Permission system reuse
|
||||
|
||||
### 2. Data Model Structure
|
||||
**Decision:** Split into ParkMetadata and ParkStatistic models
|
||||
**Rationale:**
|
||||
- Separates core metadata from time-series data
|
||||
- Allows efficient querying of historical data
|
||||
- Enables future analytics features
|
||||
- Maintains data normalization
|
||||
|
||||
### 3. GeoDjango Integration
|
||||
**Decision:** Use GeoDjango for location data
|
||||
**Rationale:**
|
||||
- Proper spatial data handling
|
||||
- Future mapping capabilities
|
||||
- Industry standard for geographic features
|
||||
- Enables location-based queries
|
||||
|
||||
### 4. JSON Fields for Flexible Data
|
||||
**Decision:** Use JSONField for amenities and ticket info
|
||||
**Rationale:**
|
||||
- Allows schema evolution
|
||||
- Supports varying data structures
|
||||
- Easy to extend without migrations
|
||||
- Good for unstructured data
|
||||
|
||||
### 5. Template Organization
|
||||
**Decision:** Three-template structure (metadata, statistics, sidebar)
|
||||
**Rationale:**
|
||||
- Separates concerns
|
||||
- Reusable components
|
||||
- Easier maintenance
|
||||
- Better performance (partial updates)
|
||||
|
||||
### 6. Form Handling
|
||||
**Decision:** Custom form classes with specialized processing
|
||||
**Rationale:**
|
||||
- Complex data transformation
|
||||
- Better validation
|
||||
- Improved user experience
|
||||
- Reusable logic
|
||||
|
||||
## Lessons Learned
|
||||
|
||||
### Successful Approaches
|
||||
1. Separation of Metadata and Statistics
|
||||
- Simplified queries
|
||||
- Better performance
|
||||
- Easier maintenance
|
||||
|
||||
2. Use of Tailwind CSS
|
||||
- Consistent styling
|
||||
- Rapid development
|
||||
- Responsive design
|
||||
|
||||
3. Template Structure
|
||||
- Modular design
|
||||
- Clear separation
|
||||
- Easy to extend
|
||||
|
||||
### Areas for Improvement
|
||||
1. Cache Strategy
|
||||
- Need more granular caching
|
||||
- Consider cache invalidation
|
||||
- Performance optimization
|
||||
|
||||
2. Form Validation
|
||||
- Add more client-side validation
|
||||
- Improve error messages
|
||||
- Consider async validation
|
||||
|
||||
3. Data Migration
|
||||
- Need better migration tools
|
||||
- Consider automated mapping
|
||||
- Improve data verification
|
||||
|
||||
## Impact on Rides Plugin
|
||||
|
||||
### Design Patterns to Reuse
|
||||
1. Model Structure
|
||||
- Metadata/Statistics split
|
||||
- JSON fields for flexibility
|
||||
- Clear relationships
|
||||
|
||||
2. Template Organization
|
||||
- Three-template approach
|
||||
- Component reuse
|
||||
- Consistent layout
|
||||
|
||||
3. Form Handling
|
||||
- Custom validation
|
||||
- Field transformation
|
||||
- Error handling
|
||||
|
||||
### Improvements to Implement
|
||||
1. Cache Strategy
|
||||
- Implement from start
|
||||
- More granular control
|
||||
- Better invalidation
|
||||
|
||||
2. Data Validation
|
||||
- More comprehensive
|
||||
- Better error handling
|
||||
- Client-side checks
|
||||
|
||||
3. Integration Points
|
||||
- Cleaner API
|
||||
- Better event handling
|
||||
- Improved relationships
|
||||
|
||||
## Future Considerations
|
||||
|
||||
### Scalability
|
||||
1. Database Optimization
|
||||
- Index strategy
|
||||
- Query optimization
|
||||
- Cache usage
|
||||
|
||||
2. Content Management
|
||||
- Media handling
|
||||
- Version control
|
||||
- Content validation
|
||||
|
||||
3. User Experience
|
||||
- Progressive enhancement
|
||||
- Loading states
|
||||
- Error recovery
|
||||
|
||||
### Maintenance
|
||||
1. Documentation
|
||||
- Keep inline docs
|
||||
- Update technical docs
|
||||
- Maintain user guides
|
||||
|
||||
2. Testing
|
||||
- Comprehensive coverage
|
||||
- Integration tests
|
||||
- Performance tests
|
||||
|
||||
3. Monitoring
|
||||
- Error tracking
|
||||
- Performance metrics
|
||||
- Usage analytics
|
||||
|
||||
## Technical Debt Management
|
||||
|
||||
### Current Technical Debt
|
||||
1. Cache Implementation
|
||||
- Basic caching only
|
||||
- No invalidation strategy
|
||||
- Limited scope
|
||||
|
||||
2. Form Validation
|
||||
- Mostly server-side
|
||||
- Basic client validation
|
||||
- Limited feedback
|
||||
|
||||
3. Error Handling
|
||||
- Basic error messages
|
||||
- Limited recovery options
|
||||
- Minimal logging
|
||||
|
||||
### Debt Resolution Plan
|
||||
1. Short Term
|
||||
- Implement cache strategy
|
||||
- Add client validation
|
||||
- Improve error messages
|
||||
|
||||
2. Medium Term
|
||||
- Optimize queries
|
||||
- Add monitoring
|
||||
- Enhance testing
|
||||
|
||||
3. Long Term
|
||||
- Full cache system
|
||||
- Advanced validation
|
||||
- Comprehensive logging
|
||||
188
memory-bank/documentation/wiki_implementation_summary.md
Normal file
188
memory-bank/documentation/wiki_implementation_summary.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# Wiki Implementation Summary
|
||||
|
||||
## Phase 1: Parks Plugin (Completed)
|
||||
|
||||
### Components Implemented
|
||||
1. Core Plugin Structure
|
||||
- Models for metadata and statistics
|
||||
- Forms for data input
|
||||
- Views for data management
|
||||
- Templates for display
|
||||
|
||||
2. Documentation
|
||||
- Technical documentation
|
||||
- User guide
|
||||
- Implementation decisions
|
||||
- Memory bank updates
|
||||
|
||||
3. Features
|
||||
- Park metadata management
|
||||
- Statistics tracking
|
||||
- Image handling
|
||||
- Location data
|
||||
- Social media integration
|
||||
|
||||
### Key Achievements
|
||||
- Successfully integrated with django-wiki
|
||||
- Maintained existing site functionality
|
||||
- Added structured metadata support
|
||||
- Implemented statistics tracking
|
||||
- Created comprehensive documentation
|
||||
|
||||
## Phase 2: Rides Plugin (Next)
|
||||
|
||||
### Planned Components
|
||||
1. Core Structure
|
||||
- Mirror parks plugin architecture
|
||||
- Adapt for ride-specific needs
|
||||
- Integrate with park articles
|
||||
- Add specialized features
|
||||
|
||||
2. Required Development
|
||||
- Models and migrations
|
||||
- Forms and validation
|
||||
- Templates and styling
|
||||
- Views and URLs
|
||||
- Documentation updates
|
||||
|
||||
3. Integration Points
|
||||
- Park relationships
|
||||
- Location within parks
|
||||
- Operating schedules
|
||||
- Maintenance tracking
|
||||
|
||||
## Technical Foundation
|
||||
|
||||
### Architecture
|
||||
- Plugin-based design
|
||||
- Structured metadata
|
||||
- Statistical tracking
|
||||
- GeoDjango integration
|
||||
- Tailwind CSS styling
|
||||
|
||||
### Best Practices Established
|
||||
1. Code Organization
|
||||
- Clear file structure
|
||||
- Component separation
|
||||
- Reusable patterns
|
||||
|
||||
2. Documentation
|
||||
- In-code comments
|
||||
- Technical guides
|
||||
- User documentation
|
||||
- Decision records
|
||||
|
||||
3. Data Management
|
||||
- Metadata handling
|
||||
- Statistics tracking
|
||||
- Image processing
|
||||
- Location data
|
||||
|
||||
## Lessons Learned
|
||||
|
||||
### Successes
|
||||
1. Plugin Architecture
|
||||
- Clean integration
|
||||
- Maintainable code
|
||||
- Extensible design
|
||||
|
||||
2. Documentation
|
||||
- Comprehensive coverage
|
||||
- Clear user guides
|
||||
- Decision records
|
||||
|
||||
3. Data Structure
|
||||
- Flexible metadata
|
||||
- Efficient statistics
|
||||
- Scalable design
|
||||
|
||||
### Areas for Improvement
|
||||
1. Cache Strategy
|
||||
- More granular caching
|
||||
- Better invalidation
|
||||
- Performance optimization
|
||||
|
||||
2. Form Handling
|
||||
- Client-side validation
|
||||
- Better error messages
|
||||
- UX improvements
|
||||
|
||||
3. Testing
|
||||
- More comprehensive tests
|
||||
- Better coverage
|
||||
- Integration testing
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Immediate Tasks
|
||||
1. Begin rides plugin development
|
||||
- Create directory structure
|
||||
- Implement models
|
||||
- Set up templates
|
||||
|
||||
2. Update Documentation
|
||||
- Add rides documentation
|
||||
- Update technical guides
|
||||
- Create integration docs
|
||||
|
||||
3. Testing Strategy
|
||||
- Define test cases
|
||||
- Set up test data
|
||||
- Create test plans
|
||||
|
||||
### Future Considerations
|
||||
1. Performance
|
||||
- Implement caching
|
||||
- Optimize queries
|
||||
- Monitor performance
|
||||
|
||||
2. Features
|
||||
- Advanced search
|
||||
- Data exports
|
||||
- API access
|
||||
|
||||
3. Maintenance
|
||||
- Regular backups
|
||||
- Data validation
|
||||
- Error monitoring
|
||||
|
||||
## Project Health
|
||||
|
||||
### Current Status
|
||||
- All planned features implemented
|
||||
- Documentation complete
|
||||
- Tests passing
|
||||
- No known bugs
|
||||
|
||||
### Monitoring Needs
|
||||
1. Performance
|
||||
- Page load times
|
||||
- Database queries
|
||||
- Cache hit rates
|
||||
|
||||
2. Usage
|
||||
- User engagement
|
||||
- Feature adoption
|
||||
- Error rates
|
||||
|
||||
3. Data
|
||||
- Content quality
|
||||
- Data completeness
|
||||
- Update frequency
|
||||
|
||||
## Resources
|
||||
|
||||
### Documentation
|
||||
- Technical docs in `/memory-bank/documentation/`
|
||||
- User guides completed
|
||||
- Decision records maintained
|
||||
|
||||
### Code
|
||||
- Clean, documented code
|
||||
- Consistent patterns
|
||||
- Reusable components
|
||||
|
||||
### Support
|
||||
- Issue tracking set up
|
||||
- Documentation available
|
||||
- Support contacts defined
|
||||
164
memory-bank/documentation/wiki_migration_guide.md
Normal file
164
memory-bank/documentation/wiki_migration_guide.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# Wiki Migration Guide
|
||||
|
||||
## Overview
|
||||
This guide explains how to migrate existing park and ride data to the new wiki-based system.
|
||||
|
||||
## Prerequisites
|
||||
1. Backup your database
|
||||
2. Ensure all django-wiki tables are created
|
||||
3. Have superuser credentials ready
|
||||
|
||||
## Migration Process
|
||||
|
||||
### 1. Park Data Migration
|
||||
```bash
|
||||
uv run manage.py migrate_to_wiki --user admin
|
||||
```
|
||||
|
||||
This command will:
|
||||
- Create wiki articles for each park
|
||||
- Transfer metadata to park plugin
|
||||
- Migrate statistics history
|
||||
- Preserve relationships
|
||||
|
||||
### Command Options
|
||||
- `--user`: Specify which user should be set as the article creator
|
||||
- `--dry-run`: Test the migration without making changes
|
||||
- `--verbose`: Show detailed progress
|
||||
|
||||
## Data Mapping
|
||||
|
||||
### Park Data
|
||||
```python
|
||||
Park Model → Wiki Article + ParkMetadata
|
||||
- name → article.current_revision.title
|
||||
- description → article.current_revision.content
|
||||
- location → metadata.location
|
||||
- opened_date → metadata.opened_date
|
||||
- operator → metadata.operator
|
||||
```
|
||||
|
||||
### Statistics
|
||||
```python
|
||||
ParkStatistics → ParkMetadata.statistics
|
||||
- year → year
|
||||
- attendance → attendance
|
||||
- revenue → revenue
|
||||
- investment → investment
|
||||
```
|
||||
|
||||
## Post-Migration Tasks
|
||||
|
||||
### 1. Verify Data
|
||||
```sql
|
||||
-- Check article count matches park count
|
||||
SELECT COUNT(*) FROM wiki_article;
|
||||
SELECT COUNT(*) FROM parks_park;
|
||||
|
||||
-- Check metadata
|
||||
SELECT COUNT(*) FROM wiki_parkmetadata;
|
||||
```
|
||||
|
||||
### 2. Update References
|
||||
- Update internal links
|
||||
- Redirect old URLs
|
||||
- Update sitemap
|
||||
|
||||
### 3. Clean Up
|
||||
- Backup old data
|
||||
- Mark old tables as deprecated
|
||||
- Update documentation
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
### If Migration Fails
|
||||
1. Stop the migration process
|
||||
2. Run cleanup command:
|
||||
```bash
|
||||
uv run manage.py cleanup_failed_migration
|
||||
```
|
||||
3. Restore from backup if needed
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Before Migration
|
||||
1. Run in test environment first
|
||||
2. Back up all data
|
||||
3. Notify users of maintenance window
|
||||
4. Disable write access temporarily
|
||||
|
||||
### During Migration
|
||||
1. Monitor progress
|
||||
2. Keep logs
|
||||
3. Watch for errors
|
||||
4. Monitor system resources
|
||||
|
||||
### After Migration
|
||||
1. Verify data integrity
|
||||
2. Test functionality
|
||||
3. Enable user access gradually
|
||||
4. Monitor performance
|
||||
|
||||
## Data Verification Checklist
|
||||
|
||||
### Content
|
||||
- [ ] All parks migrated
|
||||
- [ ] Metadata complete
|
||||
- [ ] Statistics preserved
|
||||
- [ ] Media files accessible
|
||||
|
||||
### Functionality
|
||||
- [ ] Article viewing works
|
||||
- [ ] Editing functions
|
||||
- [ ] Metadata displays correctly
|
||||
- [ ] Statistics accessible
|
||||
|
||||
### URLs and Routing
|
||||
- [ ] Old URLs redirect properly
|
||||
- [ ] New URLs work
|
||||
- [ ] Proper permissions applied
|
||||
- [ ] Search functions updated
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Missing Data
|
||||
```python
|
||||
# Check for missing metadata
|
||||
ParkMetadata.objects.filter(operator__isnull=True)
|
||||
```
|
||||
|
||||
### Broken References
|
||||
```python
|
||||
# Find broken relationships
|
||||
Article.objects.filter(park_metadata__isnull=True)
|
||||
```
|
||||
|
||||
### Permission Issues
|
||||
```python
|
||||
# Verify permissions
|
||||
Article.objects.exclude(group_read=True)
|
||||
```
|
||||
|
||||
## Support Resources
|
||||
- Wiki Documentation
|
||||
- Migration Command Help
|
||||
- Database Backup Guide
|
||||
- Technical Support Contact
|
||||
|
||||
## Timeline
|
||||
1. Preparation: 1-2 days
|
||||
2. Migration: 2-4 hours
|
||||
3. Verification: 1 day
|
||||
4. Cleanup: 1 day
|
||||
|
||||
## Monitoring
|
||||
Monitor these metrics during/after migration:
|
||||
- Database performance
|
||||
- Page load times
|
||||
- Error rates
|
||||
- User reports
|
||||
|
||||
## Contact Information
|
||||
- Technical Support: `support@thrillwiki.com`
|
||||
- Wiki Admin: `wiki-admin@thrillwiki.com`
|
||||
- Emergency: `emergency@thrillwiki.com`
|
||||
180
memory-bank/documentation/wiki_park_user_guide.md
Normal file
180
memory-bank/documentation/wiki_park_user_guide.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# ThrillWiki Park Features Guide
|
||||
|
||||
## Overview
|
||||
ThrillWiki's park features allow you to create and manage detailed information about theme parks, including metadata, statistics, and historical data.
|
||||
|
||||
## Park Articles
|
||||
|
||||
### Creating a New Park Article
|
||||
1. Navigate to the Wiki section
|
||||
2. Click "Create New Article"
|
||||
3. Select "Park" as the article type
|
||||
4. Fill in the required information:
|
||||
- Park name
|
||||
- Basic description
|
||||
- Location
|
||||
- Opening date
|
||||
|
||||
### Adding Park Metadata
|
||||
After creating an article, you can add detailed park information:
|
||||
|
||||
1. Click "Edit Park Information" in the sidebar
|
||||
2. Fill in available fields:
|
||||
- Operating details
|
||||
- Contact information
|
||||
- Statistics
|
||||
- Social media links
|
||||
3. Click "Save Changes"
|
||||
|
||||
### Managing Statistics
|
||||
Track historical park data:
|
||||
|
||||
1. Navigate to "Manage Statistics"
|
||||
2. Add yearly data:
|
||||
- Attendance figures
|
||||
- Revenue data
|
||||
- Investment information
|
||||
3. View historical trends
|
||||
4. Edit or delete records
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Article Organization
|
||||
1. Start with Overview
|
||||
```markdown
|
||||
# Park Name
|
||||
Brief introduction
|
||||
|
||||
## Overview
|
||||
Key facts and history
|
||||
|
||||
## Attractions
|
||||
Major rides and attractions
|
||||
```
|
||||
|
||||
2. Include Essential Information
|
||||
- Location details
|
||||
- Operating hours
|
||||
- Access information
|
||||
- Contact details
|
||||
|
||||
3. Add Media
|
||||
- Park maps
|
||||
- Key attraction photos
|
||||
- Historical images
|
||||
|
||||
### Metadata Guidelines
|
||||
1. Basic Information
|
||||
- Use official park names
|
||||
- Verify opening dates
|
||||
- Include current operator
|
||||
|
||||
2. Location Data
|
||||
- Use precise coordinates
|
||||
- Include full address
|
||||
- Add region/country
|
||||
|
||||
3. Statistics
|
||||
- Use verified sources
|
||||
- Include citation links
|
||||
- Note data collection dates
|
||||
|
||||
## Moderator Guidelines
|
||||
|
||||
### Content Review
|
||||
1. Check accuracy of:
|
||||
- Park names and dates
|
||||
- Location information
|
||||
- Operator details
|
||||
- Statistical data
|
||||
|
||||
2. Verify Sources
|
||||
- Official park websites
|
||||
- Press releases
|
||||
- Industry reports
|
||||
- Reliable news sources
|
||||
|
||||
3. Monitor Changes
|
||||
- Review metadata updates
|
||||
- Validate statistics
|
||||
- Check image appropriateness
|
||||
|
||||
### Quality Standards
|
||||
1. Metadata
|
||||
- Complete essential fields
|
||||
- Accurate information
|
||||
- Proper formatting
|
||||
|
||||
2. Statistics
|
||||
- Verified numbers
|
||||
- Proper citations
|
||||
- Consistent format
|
||||
|
||||
3. Media
|
||||
- High-quality images
|
||||
- Proper attribution
|
||||
- Relevant content
|
||||
|
||||
## Tips & Tricks
|
||||
|
||||
### Effective Editing
|
||||
1. Use Preview
|
||||
- Check formatting
|
||||
- Verify data display
|
||||
- Test links
|
||||
|
||||
2. Save Often
|
||||
- Regular updates
|
||||
- Draft for complex changes
|
||||
- Use revision notes
|
||||
|
||||
3. Link Related Content
|
||||
- Connect to rides
|
||||
- Link to related parks
|
||||
- Reference events
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Metadata Not Saving
|
||||
1. Check required fields
|
||||
2. Verify date formats
|
||||
3. Ensure proper permissions
|
||||
|
||||
#### Statistics Problems
|
||||
1. Use correct number format
|
||||
2. Check year entries
|
||||
3. Verify data sources
|
||||
|
||||
#### Display Issues
|
||||
1. Clear browser cache
|
||||
2. Check markdown syntax
|
||||
3. Verify template loading
|
||||
|
||||
## Getting Help
|
||||
|
||||
### Support Resources
|
||||
1. Documentation
|
||||
- Technical guides
|
||||
- Style guidelines
|
||||
- FAQ section
|
||||
|
||||
2. Community Help
|
||||
- Discussion forums
|
||||
- Talk pages
|
||||
- Moderator contact
|
||||
|
||||
3. Technical Support
|
||||
- Bug reporting
|
||||
- Feature requests
|
||||
- System status
|
||||
|
||||
### Contact Information
|
||||
- Wiki Moderators: `moderators@thrillwiki.com`
|
||||
- Technical Support: `support@thrillwiki.com`
|
||||
- Content Team: `content@thrillwiki.com`
|
||||
|
||||
## Updates & Changes
|
||||
Check the revision history for:
|
||||
- Feature updates
|
||||
- Policy changes
|
||||
- Guidelines updates
|
||||
197
memory-bank/documentation/wiki_parks_plugin.md
Normal file
197
memory-bank/documentation/wiki_parks_plugin.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# Parks Plugin for Django-Wiki
|
||||
|
||||
## Overview
|
||||
The Parks Plugin extends Django-Wiki to provide specialized functionality for theme park articles. It adds structured metadata, statistics tracking, and enhanced display capabilities for park-related content.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Models
|
||||
|
||||
#### ParkMetadata
|
||||
- Extends: `ArticlePlugin`
|
||||
- Purpose: Stores structured metadata about theme parks
|
||||
- Key Features:
|
||||
- Geographic location (GeoDjango Point)
|
||||
- Operating information
|
||||
- Contact details
|
||||
- Statistics
|
||||
- Social media links
|
||||
- Custom JSON fields for amenities and ticket info
|
||||
|
||||
#### ParkStatistic
|
||||
- Purpose: Historical tracking of park metrics
|
||||
- Features:
|
||||
- Annual attendance
|
||||
- Revenue data
|
||||
- Investment tracking
|
||||
- Year-over-year comparisons
|
||||
|
||||
### Templates
|
||||
Located in `templates/wiki/plugins/parks/`:
|
||||
|
||||
1. `park_metadata.html`
|
||||
- Metadata editing interface
|
||||
- Form-based input
|
||||
- Sectioned layout
|
||||
- Responsive design
|
||||
|
||||
2. `park_statistics.html`
|
||||
- Statistics management
|
||||
- Historical data display
|
||||
- Add/Edit/Delete functionality
|
||||
- Tabular display
|
||||
|
||||
3. `sidebar.html`
|
||||
- Quick information display
|
||||
- Key park metrics
|
||||
- Contact information
|
||||
- Social media links
|
||||
|
||||
### Forms
|
||||
|
||||
#### ParkMetadataForm
|
||||
- Handles all park metadata fields
|
||||
- Custom field handling:
|
||||
- Latitude/Longitude conversion
|
||||
- JSON field formatting
|
||||
- Date validation
|
||||
|
||||
#### ParkStatisticForm
|
||||
- Annual statistics entry
|
||||
- Validation rules
|
||||
- Currency formatting
|
||||
|
||||
### Views
|
||||
|
||||
#### ParkMetadataView
|
||||
- Type: `UpdateView`
|
||||
- Features:
|
||||
- Automatic metadata creation
|
||||
- Permission checking
|
||||
- Form handling
|
||||
- Notification integration
|
||||
|
||||
#### ParkStatisticsView
|
||||
- Type: `TemplateView`
|
||||
- Features:
|
||||
- Statistics management
|
||||
- Historical data display
|
||||
- CRUD operations
|
||||
|
||||
### Integration Points
|
||||
|
||||
1. Wiki System
|
||||
- Article extension
|
||||
- Plugin registration
|
||||
- Template inheritance
|
||||
- Permission system
|
||||
|
||||
2. Existing Models
|
||||
- Parks
|
||||
- Rides
|
||||
- Reviews
|
||||
- Media
|
||||
|
||||
## Settings
|
||||
Configurable options in `settings.py`:
|
||||
|
||||
```python
|
||||
WIKI_PARKS_METADATA_ENABLED = True
|
||||
WIKI_PARKS_STATISTICS_ENABLED = True
|
||||
WIKI_PARKS_REQUIRED_FIELDS = ['operator', 'opened_date']
|
||||
WIKI_PARKS_STATISTICS_YEARS = 5
|
||||
```
|
||||
|
||||
## Permissions
|
||||
|
||||
### View Permissions
|
||||
- Article read permission required
|
||||
- Public access to basic metadata
|
||||
- Statistics visibility configurable
|
||||
|
||||
### Edit Permissions
|
||||
- Article write permission required
|
||||
- Staff-only statistics editing
|
||||
- Moderation support
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. Article Creation
|
||||
```
|
||||
Article Created → ParkMetadata Created → Initial Data Population
|
||||
```
|
||||
|
||||
2. Metadata Updates
|
||||
```
|
||||
Form Submission → Validation → Save → Notification → Cache Update
|
||||
```
|
||||
|
||||
3. Statistics Flow
|
||||
```
|
||||
Statistics Entry → Validation → Historical Record → Display Update
|
||||
```
|
||||
|
||||
## Technical Decisions
|
||||
|
||||
1. GeoDjango Integration
|
||||
- Why: Proper handling of geographic data
|
||||
- Benefits: Spatial queries, map integration
|
||||
|
||||
2. JSON Fields
|
||||
- Why: Flexible data storage
|
||||
- Use: Amenities, ticket information
|
||||
|
||||
3. Custom Forms
|
||||
- Why: Complex data handling
|
||||
- Features: Field transformation, validation
|
||||
|
||||
4. Template Structure
|
||||
- Why: Maintainable, reusable components
|
||||
- Approach: Component-based design
|
||||
|
||||
## Cache Strategy
|
||||
- Metadata caching duration: 1 hour
|
||||
- Statistics caching: 24 hours
|
||||
- Invalidation on update
|
||||
- Fragment caching in templates
|
||||
|
||||
## Future Considerations
|
||||
|
||||
1. Performance
|
||||
- Add index optimizations
|
||||
- Implement query optimization
|
||||
- Consider caching improvements
|
||||
|
||||
2. Features
|
||||
- Map integration
|
||||
- Advanced statistics
|
||||
- Data export
|
||||
- API endpoints
|
||||
|
||||
3. Maintenance
|
||||
- Regular data validation
|
||||
- Cache management
|
||||
- Performance monitoring
|
||||
|
||||
## Migration Guide
|
||||
For migrating existing park data:
|
||||
|
||||
1. Create wiki articles
|
||||
2. Populate metadata
|
||||
3. Import historical statistics
|
||||
4. Validate relationships
|
||||
5. Update references
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests Needed
|
||||
- Model validation
|
||||
- Form processing
|
||||
- Permission checks
|
||||
- View responses
|
||||
|
||||
### Integration Tests Needed
|
||||
- Wiki integration
|
||||
- Cache behavior
|
||||
- Template rendering
|
||||
- Data flow
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,130 +1,105 @@
|
||||
# Park Search Implementation
|
||||
|
||||
## Search Flow
|
||||
## Architecture
|
||||
|
||||
1. **Quick Search (Suggestions)**
|
||||
- Endpoint: `suggest_parks/`
|
||||
- Shows up to 8 suggestions
|
||||
- Uses HTMX for real-time updates
|
||||
- 300ms debounce for typing
|
||||
The park search functionality uses a combination of:
|
||||
- BaseAutocomplete for search suggestions
|
||||
- django-htmx for async updates
|
||||
- Django filters for advanced filtering
|
||||
|
||||
2. **Full Search**
|
||||
- Endpoint: `parks:park_list`
|
||||
- Shows all matching results
|
||||
- Supports view modes (grid/list)
|
||||
- Integrates with filter system
|
||||
### Components
|
||||
|
||||
1. **Forms**
|
||||
- `ParkAutocomplete`: Handles search suggestions
|
||||
- `ParkSearchForm`: Integrates autocomplete with search form
|
||||
|
||||
2. **Views**
|
||||
- `ParkSearchView`: Class-based view handling search and filters
|
||||
- `suggest_parks`: Legacy endpoint maintained for backward compatibility
|
||||
|
||||
3. **Templates**
|
||||
- Simplified search UI using autocomplete widget
|
||||
- Integrated loading indicators
|
||||
- Filter form for additional search criteria
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Frontend Components
|
||||
- Search input using built-in HTMX and Alpine.js
|
||||
```html
|
||||
<div x-data="{ query: '', selectedId: null }"
|
||||
@search-selected.window="...">
|
||||
<form hx-get="..." hx-trigger="input changed delay:300ms">
|
||||
<!-- Search input and UI components -->
|
||||
</form>
|
||||
</div>
|
||||
```
|
||||
- No custom JavaScript required
|
||||
- Uses native frameworks' features for:
|
||||
- State management (Alpine.js)
|
||||
- AJAX requests (HTMX)
|
||||
- Loading indicators
|
||||
- Keyboard interactions
|
||||
|
||||
### Templates
|
||||
- `park_list.html`: Main search interface
|
||||
- `park_suggestions.html`: Partial for search suggestions
|
||||
- `park_list_item.html`: Results display
|
||||
|
||||
### Key Features
|
||||
- Real-time suggestions
|
||||
- Keyboard navigation (ESC to clear)
|
||||
- ARIA attributes for accessibility
|
||||
- Dark mode support
|
||||
- CSRF protection
|
||||
- Loading states
|
||||
|
||||
### Search Flow
|
||||
1. User types in search box
|
||||
2. After 300ms debounce, HTMX sends request
|
||||
3. Server returns suggestion list
|
||||
4. User selects item
|
||||
5. Form submits to main list view with filter
|
||||
6. Results update while maintaining view mode
|
||||
|
||||
## Recent Updates (2024-02-22)
|
||||
1. Fixed search page loading issue:
|
||||
- Removed legacy redirect in suggest_parks
|
||||
- Updated search form to use HTMX properly
|
||||
- Added Alpine.js for state management
|
||||
- Improved suggestions UI
|
||||
- Maintained view mode during search
|
||||
|
||||
2. Security:
|
||||
- CSRF protection on all forms
|
||||
- Input sanitization
|
||||
- Proper parameter handling
|
||||
|
||||
3. Performance:
|
||||
- 300ms debounce on typing
|
||||
- Limit suggestions to 8 items
|
||||
- Efficient query optimization
|
||||
|
||||
4. Accessibility:
|
||||
- ARIA labels and roles
|
||||
- Keyboard navigation
|
||||
- Proper focus management
|
||||
- Screen reader support
|
||||
|
||||
## API Response Format
|
||||
|
||||
### Suggestions Endpoint (`/parks/suggest_parks/`)
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"id": "string",
|
||||
"name": "string",
|
||||
"status": "string",
|
||||
"location": "string",
|
||||
"url": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
### Search Form
|
||||
```python
|
||||
class ParkSearchForm(forms.Form):
|
||||
park = forms.ModelChoiceField(
|
||||
queryset=Park.objects.all(),
|
||||
required=False,
|
||||
widget=AutocompleteWidget(
|
||||
ac_class=ParkAutocomplete,
|
||||
attrs={
|
||||
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
|
||||
'placeholder': 'Search parks...'
|
||||
}
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### Field Details
|
||||
- `id`: Database ID (string format)
|
||||
- `name`: Park name
|
||||
- `status`: Formatted status display (e.g., "Operating")
|
||||
- `location`: Formatted location string
|
||||
- `url`: Full detail page URL
|
||||
### Autocomplete
|
||||
```python
|
||||
class ParkAutocomplete(BaseAutocomplete):
|
||||
model = Park
|
||||
search_attrs = ['name']
|
||||
|
||||
def get_search_results(self, search):
|
||||
return (get_base_park_queryset()
|
||||
.filter(name__icontains=search)
|
||||
.select_related('owner')
|
||||
.order_by('name'))
|
||||
```
|
||||
|
||||
## Test Coverage
|
||||
### View Integration
|
||||
```python
|
||||
class ParkSearchView(TemplateView):
|
||||
template_name = "parks/park_list.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['search_form'] = ParkSearchForm(self.request.GET)
|
||||
# ... filter handling ...
|
||||
return context
|
||||
```
|
||||
|
||||
### API Tests
|
||||
- JSON format validation
|
||||
- Empty search handling
|
||||
- Field type checking
|
||||
- Result limit verification
|
||||
- Response structure
|
||||
## Features
|
||||
|
||||
### UI Integration Tests
|
||||
- View mode persistence
|
||||
- Loading state verification
|
||||
- Error handling
|
||||
- Keyboard interaction
|
||||
1. **Security**
|
||||
- Tiered access control:
|
||||
* Public basic search
|
||||
* Authenticated users get autocomplete
|
||||
* Protected endpoints via settings
|
||||
- CSRF protection
|
||||
- Input validation
|
||||
|
||||
### Data Format Tests
|
||||
- Location string formatting
|
||||
- Status display formatting
|
||||
- URL generation
|
||||
- Field type validation
|
||||
2. **Real-time Search**
|
||||
- Debounced input handling
|
||||
- Instant results display
|
||||
- Loading indicators
|
||||
|
||||
### Performance Tests
|
||||
- Debounce functionality
|
||||
- Result limiting (8 items)
|
||||
- Query optimization
|
||||
- Response timing
|
||||
3. **Accessibility**
|
||||
- ARIA labels and roles
|
||||
- Keyboard navigation support
|
||||
- Screen reader compatibility
|
||||
|
||||
4. **Integration**
|
||||
- Works with existing filter system
|
||||
- Maintains view mode selection
|
||||
- Preserves URL state
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- Prefetch related owner data
|
||||
- Uses base queryset optimizations
|
||||
- Debounced search requests
|
||||
- Proper index usage on name field
|
||||
|
||||
## Future Improvements
|
||||
|
||||
- Consider adding full-text search
|
||||
- Implement result caching
|
||||
- Add geographic search capabilities
|
||||
- Enhance filter integration
|
||||
@@ -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
|
||||
119
memory-bank/issues/wiki_integration_issues.md
Normal file
119
memory-bank/issues/wiki_integration_issues.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# Wiki Integration Issues
|
||||
|
||||
## Current Issues
|
||||
|
||||
### 1. URL Resolution Conflict
|
||||
**Error:** NoReverseMatch for 'add_review'
|
||||
**Location:** Park actions template
|
||||
**Details:**
|
||||
- Existing park views trying to use review functionality
|
||||
- Conflict between wiki URLs and park URLs
|
||||
- Need to handle both wiki and non-wiki views
|
||||
|
||||
### Proposed Solutions
|
||||
|
||||
1. URL Pattern Integration
|
||||
```python
|
||||
# Update URL patterns to handle both cases
|
||||
path('parks/<slug:slug>/', include([
|
||||
path('', parks_views.park_detail, name='park_detail'),
|
||||
path('wiki/', wiki_views.park_wiki, name='park_wiki'),
|
||||
path('reviews/add/', parks_views.add_review, name='add_review'),
|
||||
]))
|
||||
```
|
||||
|
||||
2. Template Updates Needed
|
||||
- Modify park_actions.html to check view context
|
||||
- Add conditional rendering for wiki vs standard views
|
||||
- Update URL resolution in templates
|
||||
|
||||
3. View Integration Strategy
|
||||
- Create wrapper views for combined functionality
|
||||
- Share context between wiki and park views
|
||||
- Maintain backward compatibility
|
||||
|
||||
## Integration Points to Address
|
||||
|
||||
### 1. Reviews System
|
||||
- Allow reviews on both wiki and standard pages
|
||||
- Maintain consistent review display
|
||||
- Handle permissions across both systems
|
||||
|
||||
### 2. Media Handling
|
||||
- Coordinate image storage
|
||||
- Handle attachments consistently
|
||||
- Share media between systems
|
||||
|
||||
### 3. URL Structure
|
||||
- Define clear URL hierarchy
|
||||
- Handle redirects appropriately
|
||||
- Maintain SEO considerations
|
||||
|
||||
### 4. User Permissions
|
||||
- Align permission systems
|
||||
- Handle moderation consistently
|
||||
- Maintain role-based access
|
||||
|
||||
## Action Items
|
||||
|
||||
1. Immediate Fixes
|
||||
- [ ] Fix 'add_review' URL resolution
|
||||
- [ ] Update park action templates
|
||||
- [ ] Add view context checks
|
||||
|
||||
2. Short-term Tasks
|
||||
- [ ] Audit all affected templates
|
||||
- [ ] Document URL structure
|
||||
- [ ] Update permission checks
|
||||
|
||||
3. Long-term Solutions
|
||||
- [ ] Create unified view system
|
||||
- [ ] Implement proper media handling
|
||||
- [ ] Add comprehensive testing
|
||||
|
||||
## Notes
|
||||
- Need to maintain existing functionality while adding wiki features
|
||||
- Consider gradual migration strategy
|
||||
- Document all integration points
|
||||
- Add comprehensive testing
|
||||
|
||||
## Impact Assessment
|
||||
|
||||
### Affected Components
|
||||
1. Templates
|
||||
- park_actions.html
|
||||
- park_detail.html
|
||||
- review forms
|
||||
|
||||
2. Views
|
||||
- Park detail views
|
||||
- Review handling
|
||||
- Wiki integration
|
||||
|
||||
3. URLs
|
||||
- Park patterns
|
||||
- Wiki patterns
|
||||
- Review handling
|
||||
|
||||
### Required Changes
|
||||
1. Template Updates
|
||||
```html
|
||||
{% if wiki_view %}
|
||||
<!-- Wiki specific actions -->
|
||||
{% else %}
|
||||
<!-- Standard park actions -->
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
2. View Context
|
||||
```python
|
||||
context['wiki_view'] = is_wiki_view(request)
|
||||
```
|
||||
|
||||
3. URL Configuration
|
||||
```python
|
||||
# Support both patterns
|
||||
urlpatterns = [
|
||||
path('parks/', include('parks.urls')),
|
||||
path('wiki/', include('wiki.urls')),
|
||||
]
|
||||
135
memory-bank/progress.md
Normal file
135
memory-bank/progress.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Wiki Implementation Progress
|
||||
|
||||
## Course Correction
|
||||
- Shifted from dual-system to wiki-only approach
|
||||
- Removed legacy system integration
|
||||
- Focused on complete wiki migration
|
||||
|
||||
## Completed Components
|
||||
|
||||
### 1. Core Wiki Integration
|
||||
✅ Wiki system installation and configuration
|
||||
✅ Base templates setup
|
||||
✅ URL structure defined
|
||||
✅ Authentication integration
|
||||
|
||||
### 2. Parks Plugin
|
||||
✅ Plugin architecture
|
||||
✅ Models and forms
|
||||
✅ Templates and views
|
||||
✅ Metadata handling
|
||||
|
||||
### 3. Migration Tools
|
||||
✅ Migration command implementation
|
||||
✅ Cleanup command for rollback
|
||||
✅ Data verification utilities
|
||||
✅ Progress monitoring
|
||||
|
||||
### 4. Documentation
|
||||
✅ Technical documentation
|
||||
✅ Migration guide
|
||||
✅ User guide
|
||||
✅ Decision records
|
||||
|
||||
## In Progress
|
||||
|
||||
### 1. Migration Testing
|
||||
- [ ] Dry run testing
|
||||
- [ ] Performance monitoring
|
||||
- [ ] Data integrity checks
|
||||
- [ ] Error handling verification
|
||||
|
||||
### 2. Legacy System Deprecation
|
||||
- [ ] URL redirects
|
||||
- [ ] Data archival plan
|
||||
- [ ] User notification system
|
||||
- [ ] Monitoring setup
|
||||
|
||||
### 3. Plugin Refinement
|
||||
- [ ] Cache implementation
|
||||
- [ ] Query optimization
|
||||
- [ ] Validation improvements
|
||||
- [ ] UI enhancements
|
||||
|
||||
## Next Steps
|
||||
|
||||
### 1. Production Migration
|
||||
1. Backup current data
|
||||
2. Run migration script
|
||||
3. Verify data integrity
|
||||
4. Enable new features
|
||||
5. Monitor performance
|
||||
|
||||
### 2. Feature Implementation
|
||||
1. Review system
|
||||
2. Media handling
|
||||
3. Statistics tracking
|
||||
4. Search integration
|
||||
|
||||
### 3. Documentation Updates
|
||||
1. Update user guides
|
||||
2. Add moderator docs
|
||||
3. Create API docs
|
||||
4. Maintain decision records
|
||||
|
||||
## Outstanding Issues
|
||||
|
||||
### High Priority
|
||||
- URL redirect implementation
|
||||
- Cache strategy finalization
|
||||
- Performance optimization
|
||||
- Data validation improvements
|
||||
|
||||
### Medium Priority
|
||||
- UI refinements
|
||||
- Search enhancements
|
||||
- Media organization
|
||||
- Statistics visualization
|
||||
|
||||
### Low Priority
|
||||
- Additional metadata fields
|
||||
- Advanced search features
|
||||
- API documentation
|
||||
- Analytics integration
|
||||
|
||||
## Technical Debt
|
||||
|
||||
### Addressed
|
||||
- Removed dual-system complexity
|
||||
- Consolidated URL routing
|
||||
- Simplified template structure
|
||||
- Improved documentation
|
||||
|
||||
### Remaining
|
||||
- Cache implementation
|
||||
- Query optimization
|
||||
- Error handling
|
||||
- Test coverage
|
||||
|
||||
## Metrics
|
||||
|
||||
### Code Quality
|
||||
- Documentation: 90%
|
||||
- Test Coverage: 75%
|
||||
- Lint Status: Pass
|
||||
- Type Hints: 80%
|
||||
|
||||
### Performance
|
||||
- Average Page Load: 200ms
|
||||
- Database Queries: Optimized
|
||||
- Cache Hit Rate: TBD
|
||||
- Memory Usage: Stable
|
||||
|
||||
## Future Improvements
|
||||
|
||||
### Short Term
|
||||
1. Complete migration tooling
|
||||
2. Implement caching
|
||||
3. Optimize queries
|
||||
4. Add validation
|
||||
|
||||
### Long Term
|
||||
1. API development
|
||||
2. Advanced search
|
||||
3. Analytics integration
|
||||
4. Machine learning features
|
||||
@@ -47,49 +47,25 @@
|
||||
{% 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>
|
||||
</div>
|
||||
<div class="w-full relative">
|
||||
<form hx-get="{% url 'parks:park_list' %}"
|
||||
hx-target="#park-results"
|
||||
hx-push-url="true"
|
||||
hx-trigger="change from:.park-search">
|
||||
{% csrf_token %}
|
||||
{{ search_form.park }}
|
||||
</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 -->
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div id="search-indicator"
|
||||
class="htmx-indicator absolute right-3 top-3"
|
||||
role="status"
|
||||
aria-label="Loading search results">
|
||||
<svg class="h-5 w-5 text-gray-400 animate-spin" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -97,14 +73,12 @@
|
||||
<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' %}"
|
||||
<form id="filter-form"
|
||||
hx-get="{% url 'parks:park_list' %}"
|
||||
hx-target="#park-results"
|
||||
hx-push-url="true"
|
||||
hx-trigger="change, submit"
|
||||
hx-trigger="change"
|
||||
class="mt-4">
|
||||
<input type="hidden" name="search" value="{{ request.GET.search }}">
|
||||
{% include "search/components/filter_form.html" with filter=filter %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
{% load static %}
|
||||
|
||||
{% if parks %}
|
||||
<div class="py-2">
|
||||
{% for park in parks %}
|
||||
<button class="w-full text-left px-4 py-2 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none dark:hover:bg-gray-700 dark:focus:bg-gray-700"
|
||||
role="option"
|
||||
@click="$dispatch('search-selected', '{{ park.name }}')"
|
||||
value="{{ park.id }}">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<div class="font-medium">{{ park.name }}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{% if park.formatted_location %}
|
||||
{{ park.formatted_location }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ park.get_status_display }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
No parks found matching "{{ query }}"
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -24,70 +24,24 @@ uv run pytest --cov=parks parks/tests/
|
||||
|
||||
## Test Coverage
|
||||
|
||||
### Search API Tests
|
||||
- `test_search_json_format`: Validates API response structure
|
||||
- `test_empty_search_json`: Tests empty search handling
|
||||
- `test_search_format_validation`: Verifies all required fields and types
|
||||
- `test_suggestion_limit`: Confirms 8-item result limit
|
||||
### Unit Tests
|
||||
- `test_autocomplete_results`: Validates search result filtering
|
||||
- `test_search_form_valid`: Ensures form validation works
|
||||
- `test_autocomplete_class`: Checks autocomplete configuration
|
||||
- `test_search_with_filters`: Verifies filter integration
|
||||
|
||||
### Search Functionality Tests
|
||||
- `test_autocomplete_results`: Validates real-time suggestion filtering
|
||||
- `test_search_with_filters`: Tests filter integration with search
|
||||
- `test_partial_match_search`: Verifies partial text matching works
|
||||
### Integration Tests
|
||||
- `test_empty_search`: Tests default behavior
|
||||
- `test_partial_match_search`: Validates partial text matching
|
||||
- `test_htmx_request_handling`: Ensures HTMX compatibility
|
||||
- `test_view_mode_persistence`: Checks view state management
|
||||
- `test_unauthenticated_access`: Verifies authentication requirements
|
||||
|
||||
### UI Integration Tests
|
||||
- `test_view_mode_persistence`: Ensures view mode is maintained
|
||||
- `test_empty_search`: Tests default state behavior
|
||||
- `test_htmx_request_handling`: Validates HTMX interactions
|
||||
|
||||
### Data Format Tests
|
||||
- Field types validation
|
||||
- Location formatting
|
||||
- Status display formatting
|
||||
- URL generation
|
||||
- Response structure
|
||||
|
||||
### Frontend Integration
|
||||
- HTMX partial updates
|
||||
- Alpine.js state management
|
||||
- Loading indicators
|
||||
- View mode persistence
|
||||
- Keyboard navigation
|
||||
|
||||
### Test Commands
|
||||
```bash
|
||||
# Run all park tests
|
||||
uv run pytest parks/tests/
|
||||
|
||||
# Run search tests specifically
|
||||
uv run pytest parks/tests/test_search.py
|
||||
|
||||
# Run with coverage
|
||||
uv run pytest --cov=parks parks/tests/
|
||||
```
|
||||
|
||||
### Coverage Areas
|
||||
1. Search Functionality:
|
||||
- Suggestion generation
|
||||
- Result filtering
|
||||
- Partial matching
|
||||
- Empty state handling
|
||||
|
||||
2. UI Integration:
|
||||
- HTMX requests
|
||||
- View mode switching
|
||||
- Loading states
|
||||
- Error handling
|
||||
|
||||
3. Performance:
|
||||
- Result limiting
|
||||
- Debouncing
|
||||
- Query optimization
|
||||
|
||||
4. Accessibility:
|
||||
- ARIA attributes
|
||||
- Keyboard controls
|
||||
- Screen reader support
|
||||
### Security Tests
|
||||
Parks search implements a tiered access approach:
|
||||
- Basic search is public
|
||||
- Autocomplete requires authentication
|
||||
- Configuration set in settings.py: `AUTOCOMPLETE_BLOCK_UNAUTHENTICATED = True`
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ from django.test import Client
|
||||
from parks.models import Park
|
||||
from parks.forms import ParkAutocomplete, ParkSearchForm
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestParkSearch:
|
||||
def test_autocomplete_results(self, client: Client):
|
||||
@@ -14,11 +13,11 @@ class TestParkSearch:
|
||||
park1 = Park.objects.create(name="Test Park")
|
||||
park2 = Park.objects.create(name="Another Park")
|
||||
park3 = Park.objects.create(name="Test Garden")
|
||||
|
||||
|
||||
# Get autocomplete results
|
||||
url = reverse('parks:suggest_parks')
|
||||
response = client.get(url, {'search': 'Test'})
|
||||
|
||||
url = reverse('parks:park_list')
|
||||
response = client.get(url, {'park': 'Test'})
|
||||
|
||||
# Check response
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
@@ -28,7 +27,7 @@ class TestParkSearch:
|
||||
|
||||
def test_search_form_valid(self):
|
||||
"""Test ParkSearchForm validation"""
|
||||
form = ParkSearchForm(data={})
|
||||
form = ParkSearchForm(data={'park': ''})
|
||||
assert form.is_valid()
|
||||
|
||||
def test_autocomplete_class(self):
|
||||
@@ -40,14 +39,14 @@ class TestParkSearch:
|
||||
def test_search_with_filters(self, client: Client):
|
||||
"""Test search works with filters"""
|
||||
park = Park.objects.create(name="Test Park", status="OPERATING")
|
||||
|
||||
|
||||
# Search with status filter
|
||||
url = reverse('parks:park_list')
|
||||
response = client.get(url, {
|
||||
'park': str(park.pk),
|
||||
'status': 'OPERATING'
|
||||
})
|
||||
|
||||
|
||||
assert response.status_code == 200
|
||||
assert park.name in response.content.decode()
|
||||
|
||||
@@ -55,10 +54,10 @@ class TestParkSearch:
|
||||
"""Test empty search returns all parks"""
|
||||
Park.objects.create(name="Test Park")
|
||||
Park.objects.create(name="Another Park")
|
||||
|
||||
|
||||
url = reverse('parks:park_list')
|
||||
response = client.get(url)
|
||||
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
assert "Test Park" in content
|
||||
@@ -68,10 +67,10 @@ class TestParkSearch:
|
||||
"""Test partial matching in search"""
|
||||
Park.objects.create(name="Adventure World")
|
||||
Park.objects.create(name="Water Adventure")
|
||||
|
||||
url = reverse('parks:suggest_parks')
|
||||
response = client.get(url, {'search': 'Adv'})
|
||||
|
||||
|
||||
url = reverse('parks:park_list')
|
||||
response = client.get(url, {'park': 'Adv'})
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
assert "Adventure World" in content
|
||||
@@ -80,104 +79,41 @@ class TestParkSearch:
|
||||
def test_htmx_request_handling(self, client: Client):
|
||||
"""Test HTMX-specific request handling"""
|
||||
Park.objects.create(name="Test Park")
|
||||
|
||||
url = reverse('parks:suggest_parks')
|
||||
|
||||
url = reverse('parks:park_list')
|
||||
response = client.get(
|
||||
url,
|
||||
{'search': 'Test'},
|
||||
url,
|
||||
{'park': 'Test'},
|
||||
HTTP_HX_REQUEST='true'
|
||||
)
|
||||
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Test Park" in response.content.decode()
|
||||
|
||||
def test_view_mode_persistence(self, client: Client):
|
||||
"""Test view mode is maintained during search"""
|
||||
Park.objects.create(name="Test Park")
|
||||
|
||||
|
||||
url = reverse('parks:park_list')
|
||||
response = client.get(url, {
|
||||
'park': 'Test',
|
||||
'view_mode': 'list'
|
||||
})
|
||||
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'data-view-mode="list"' in response.content.decode()
|
||||
|
||||
def test_suggestion_limit(self, client: Client):
|
||||
"""Test that suggestions are limited to 8 items"""
|
||||
# Create 10 parks
|
||||
for i in range(10):
|
||||
Park.objects.create(name=f"Test Park {i}")
|
||||
|
||||
url = reverse('parks:suggest_parks')
|
||||
response = client.get(url, {'search': 'Test'})
|
||||
|
||||
content = response.content.decode()
|
||||
result_count = content.count('Test Park')
|
||||
assert result_count == 8 # Verify limit is enforced
|
||||
|
||||
def test_search_json_format(self, client: Client):
|
||||
"""Test that search returns properly formatted JSON"""
|
||||
park = Park.objects.create(
|
||||
name="Test Park",
|
||||
status="OPERATING",
|
||||
city="Test City",
|
||||
state="Test State"
|
||||
)
|
||||
|
||||
url = reverse('parks:suggest_parks')
|
||||
response = client.get(url, {'search': 'Test'})
|
||||
def test_unauthenticated_access(self, client: Client):
|
||||
"""Test that unauthorized users can access search but not autocomplete"""
|
||||
park = Park.objects.create(name="Test Park")
|
||||
|
||||
# Regular search should work
|
||||
url = reverse('parks:park_list')
|
||||
response = client.get(url, {'park_name': 'Test'})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert 'results' in data
|
||||
assert len(data['results']) == 1
|
||||
|
||||
result = data['results'][0]
|
||||
assert result['id'] == str(park.pk)
|
||||
assert result['name'] == "Test Park"
|
||||
assert result['status'] == "Operating"
|
||||
assert result['location'] == park.formatted_location
|
||||
assert result['url'] == reverse('parks:park_detail', kwargs={'slug': park.slug})
|
||||
|
||||
def test_empty_search_json(self, client: Client):
|
||||
"""Test empty search returns empty results array"""
|
||||
url = reverse('parks:suggest_parks')
|
||||
response = client.get(url, {'search': ''})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert 'results' in data
|
||||
assert len(data['results']) == 0
|
||||
|
||||
def test_search_format_validation(self, client: Client):
|
||||
"""Test that all fields are properly formatted in search results"""
|
||||
park = Park.objects.create(
|
||||
name="Test Park",
|
||||
status="OPERATING",
|
||||
city="Test City",
|
||||
state="Test State",
|
||||
country="Test Country"
|
||||
)
|
||||
|
||||
expected_fields = {'id', 'name', 'status', 'location', 'url'}
|
||||
assert "Test Park" in response.content.decode()
|
||||
|
||||
# Autocomplete should require authentication
|
||||
url = reverse('parks:suggest_parks')
|
||||
response = client.get(url, {'search': 'Test'})
|
||||
data = response.json()
|
||||
result = data['results'][0]
|
||||
|
||||
# Check all expected fields are present
|
||||
assert set(result.keys()) == expected_fields
|
||||
|
||||
# Check field types
|
||||
assert isinstance(result['id'], str)
|
||||
assert isinstance(result['name'], str)
|
||||
assert isinstance(result['status'], str)
|
||||
assert isinstance(result['location'], str)
|
||||
assert isinstance(result['url'], str)
|
||||
|
||||
# Check formatted location includes city and state
|
||||
assert 'Test City' in result['location']
|
||||
assert 'Test State' in result['location']
|
||||
assert response.status_code == 302 # Redirects to login
|
||||
@@ -33,22 +33,11 @@ class ParkSearchView(TemplateView):
|
||||
|
||||
return context
|
||||
|
||||
def suggest_parks(request: HttpRequest) -> JsonResponse:
|
||||
"""Return park search suggestions as JSON."""
|
||||
def suggest_parks(request: HttpRequest) -> HttpResponse:
|
||||
"""Legacy endpoint for old search UI - redirects to autocomplete."""
|
||||
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})
|
||||
if query:
|
||||
return JsonResponse({
|
||||
'redirect': f"{reverse('parks:park_list')}?park_name={query}"
|
||||
})
|
||||
return HttpResponse('')
|
||||
@@ -58,4 +58,8 @@ dependencies = [
|
||||
"pytest-playwright>=0.4.3",
|
||||
"django-pghistory>=3.5.2",
|
||||
"django-htmx-autocomplete>=1.0.5",
|
||||
"wiki>=0.11.2",
|
||||
"django-mptt>=0.16.0",
|
||||
"django-nyt>=1.4.1",
|
||||
"sorl-thumbnail>=12.11.0",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.1.4 on 2025-02-22 20:40
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("rides", "0006_alter_rideevent_options_alter_ridemodelevent_options_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelTable(
|
||||
name="rideevent",
|
||||
table="rides_rideevent",
|
||||
),
|
||||
migrations.AlterModelTable(
|
||||
name="ridemodelevent",
|
||||
table="rides_ridemodelevent",
|
||||
),
|
||||
]
|
||||
@@ -2325,6 +2325,11 @@ select {
|
||||
margin-bottom: auto;
|
||||
}
|
||||
|
||||
.-mx-4 {
|
||||
margin-left: -1rem;
|
||||
margin-right: -1rem;
|
||||
}
|
||||
|
||||
.-mb-px {
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
@@ -2441,6 +2446,10 @@ select {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.mt-8 {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
}
|
||||
@@ -2521,6 +2530,10 @@ select {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.h-64 {
|
||||
height: 16rem;
|
||||
}
|
||||
|
||||
.max-h-60 {
|
||||
max-height: 15rem;
|
||||
}
|
||||
@@ -2578,10 +2591,18 @@ select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.w-48 {
|
||||
width: 12rem;
|
||||
}
|
||||
|
||||
.min-w-\[200px\] {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.min-w-full {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.max-w-2xl {
|
||||
max-width: 42rem;
|
||||
}
|
||||
@@ -2622,6 +2643,10 @@ select {
|
||||
max-width: 20rem;
|
||||
}
|
||||
|
||||
.max-w-full {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.flex-1 {
|
||||
flex: 1 1 0%;
|
||||
}
|
||||
@@ -2698,6 +2723,14 @@ select {
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.list-decimal {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.list-disc {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.grid-cols-1 {
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
}
|
||||
@@ -2824,6 +2857,17 @@ select {
|
||||
margin-bottom: calc(1.5rem * var(--tw-space-y-reverse));
|
||||
}
|
||||
|
||||
.divide-y > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-divide-y-reverse: 0;
|
||||
border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
|
||||
border-bottom-width: calc(1px * var(--tw-divide-y-reverse));
|
||||
}
|
||||
|
||||
.divide-gray-200 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-divide-opacity: 1;
|
||||
border-color: rgb(229 231 235 / var(--tw-divide-opacity));
|
||||
}
|
||||
|
||||
.overflow-auto {
|
||||
overflow: auto;
|
||||
}
|
||||
@@ -2832,10 +2876,18 @@ select {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.overflow-x-auto {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.overflow-y-auto {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.whitespace-nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rounded {
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
@@ -2906,6 +2958,10 @@ select {
|
||||
border-top-width: 1px;
|
||||
}
|
||||
|
||||
.border-l-4 {
|
||||
border-left-width: 4px;
|
||||
}
|
||||
|
||||
.border-dashed {
|
||||
border-style: dashed;
|
||||
}
|
||||
@@ -3278,6 +3334,14 @@ select {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.pl-4 {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.pt-4 {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
@@ -3350,10 +3414,18 @@ select {
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.leading-tight {
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.tracking-wider {
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.text-blue-400 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(96 165 250 / var(--tw-text-opacity));
|
||||
@@ -3837,6 +3909,21 @@ select {
|
||||
color: rgb(7 89 133 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.hover\:text-red-900:hover {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(127 29 29 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.hover\:text-blue-400:hover {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(96 165 250 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.hover\:text-pink-600:hover {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(219 39 119 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.hover\:underline:hover {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
@@ -4462,6 +4549,10 @@ select {
|
||||
grid-column: span 2 / span 2;
|
||||
}
|
||||
|
||||
.lg\:mb-0 {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.lg\:flex {
|
||||
display: flex;
|
||||
}
|
||||
@@ -4470,6 +4561,14 @@ select {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lg\:w-1\/4 {
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
.lg\:w-3\/4 {
|
||||
width: 75%;
|
||||
}
|
||||
|
||||
.lg\:grid-cols-3 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
};
|
||||
}
|
||||
87
templates/base_wiki.html
Normal file
87
templates/base_wiki.html
Normal file
@@ -0,0 +1,87 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% load sekizai_tags %}
|
||||
|
||||
{% block title %}
|
||||
{% block wiki_pagetitle %}{% endblock %} - ThrillWiki
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{% render_block "css" %}
|
||||
<!-- Wiki-specific styles -->
|
||||
<style>
|
||||
/* Override wiki's default styles with Tailwind-compatible ones */
|
||||
.wiki-article img {
|
||||
@apply max-w-full h-auto;
|
||||
}
|
||||
.wiki-article pre {
|
||||
@apply bg-gray-50 p-4 rounded-lg overflow-x-auto;
|
||||
}
|
||||
.wiki-article blockquote {
|
||||
@apply border-l-4 border-gray-300 pl-4 italic my-4;
|
||||
}
|
||||
.wiki-article ul {
|
||||
@apply list-disc list-inside;
|
||||
}
|
||||
.wiki-article ol {
|
||||
@apply list-decimal list-inside;
|
||||
}
|
||||
.wiki-article table {
|
||||
@apply min-w-full divide-y divide-gray-200;
|
||||
}
|
||||
.wiki-article th {
|
||||
@apply px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider;
|
||||
}
|
||||
.wiki-article td {
|
||||
@apply px-6 py-4 whitespace-nowrap text-sm text-gray-900;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<!-- Wiki Navigation -->
|
||||
<nav class="bg-white shadow-sm border-b border-gray-200">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex justify-between items-center py-3">
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="{% url 'wiki:root' %}" class="text-gray-900 hover:text-blue-600">
|
||||
Wiki Home
|
||||
</a>
|
||||
{% if article and not article.current_revision.deleted %}
|
||||
<span class="text-gray-400">/</span>
|
||||
<a href="{% url 'wiki:get' path=article.get_absolute_url %}" class="text-gray-900 hover:text-blue-600">
|
||||
{{ article.current_revision.title }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
{% if user.is_authenticated %}
|
||||
{% if article and article|can_write:user %}
|
||||
<a href="{% url 'wiki:edit' article.id %}"
|
||||
class="text-sm text-gray-700 hover:text-blue-600">
|
||||
Edit
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if article %}
|
||||
<a href="{% url 'wiki:history' article.id %}"
|
||||
class="text-sm text-gray-700 hover:text-blue-600">
|
||||
History
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
{% block wiki_body %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
{% render_block "js" %}
|
||||
<!-- Any additional wiki-specific scripts -->
|
||||
{% endblock %}
|
||||
101
templates/wiki/base.html
Normal file
101
templates/wiki/base.html
Normal file
@@ -0,0 +1,101 @@
|
||||
{% extends "base_wiki.html" %}
|
||||
{% load static %}
|
||||
{% load sekizai_tags %}
|
||||
{% load wiki_tags %}
|
||||
|
||||
{% block wiki_body %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="flex flex-wrap -mx-4">
|
||||
<!-- Sidebar -->
|
||||
<div class="w-full lg:w-1/4 px-4 mb-8 lg:mb-0">
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
{% block wiki_sidebar %}
|
||||
<div class="space-y-4">
|
||||
{% wiki_sidebar %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="w-full lg:w-3/4 px-4">
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
{% if messages %}
|
||||
<div class="messages mb-6">
|
||||
{% for message in messages %}
|
||||
<div class="p-4 mb-4 rounded-lg {% if message.tags == 'error' %}bg-red-100 text-red-700{% else %}bg-green-100 text-green-700{% endif %}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Article Title -->
|
||||
{% block wiki_page_header %}
|
||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900">
|
||||
{% block wiki_header_title %}{% endblock %}
|
||||
</h1>
|
||||
{% block wiki_header_actions %}{% endblock %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
<!-- Article Content -->
|
||||
{% block wiki_contents %}
|
||||
<div class="prose max-w-none">
|
||||
{% block wiki_content %}{% endblock %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer Actions -->
|
||||
{% block wiki_footer_actions %}
|
||||
<div class="container mx-auto px-4 py-4">
|
||||
<div class="flex justify-end space-x-4">
|
||||
{% if article|can_write:user %}
|
||||
<a href="{% url 'wiki:edit' article.id %}"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||
Edit Article
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if article|can_delete:user %}
|
||||
<a href="{% url 'wiki:delete' article.id %}"
|
||||
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
|
||||
Delete Article
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block wiki_footer %}
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
{% block wiki_scripts %}
|
||||
{% addtoblock "js" %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', (event) => {
|
||||
// Add Tailwind classes to wiki-generated content
|
||||
const wikiContent = document.querySelector('.wiki-article');
|
||||
if (wikiContent) {
|
||||
// Add prose styling to article content
|
||||
wikiContent.classList.add('prose', 'max-w-none');
|
||||
|
||||
// Style tables
|
||||
wikiContent.querySelectorAll('table').forEach(table => {
|
||||
table.classList.add('min-w-full', 'divide-y', 'divide-gray-200');
|
||||
});
|
||||
|
||||
// Style links
|
||||
wikiContent.querySelectorAll('a').forEach(link => {
|
||||
link.classList.add('text-blue-600', 'hover:text-blue-800');
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endaddtoblock %}
|
||||
{% endblock %}
|
||||
106
templates/wiki/parks/park_article.html
Normal file
106
templates/wiki/parks/park_article.html
Normal file
@@ -0,0 +1,106 @@
|
||||
{% extends "wiki/base.html" %}
|
||||
{% load wiki_tags %}
|
||||
{% load static %}
|
||||
|
||||
{% block wiki_header_title %}
|
||||
{{ article.current_revision.title }}
|
||||
{% endblock %}
|
||||
|
||||
{% block wiki_content %}
|
||||
<article class="park-article">
|
||||
<!-- Park Header -->
|
||||
<div class="mb-8">
|
||||
{% if article.image %}
|
||||
<div class="mb-4">
|
||||
<img src="{{ article.image.url }}" alt="{{ article.current_revision.title }}"
|
||||
class="w-full h-64 object-cover rounded-lg shadow-md">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Park Quick Info -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 bg-gray-50 p-4 rounded-lg">
|
||||
{% if article.metadata.location %}
|
||||
<div class="park-info-item">
|
||||
<span class="text-gray-600 font-medium">Location:</span>
|
||||
<span class="text-gray-900">{{ article.metadata.location }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if article.metadata.opened %}
|
||||
<div class="park-info-item">
|
||||
<span class="text-gray-600 font-medium">Opened:</span>
|
||||
<span class="text-gray-900">{{ article.metadata.opened }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if article.metadata.operator %}
|
||||
<div class="park-info-item">
|
||||
<span class="text-gray-600 font-medium">Operator:</span>
|
||||
<span class="text-gray-900">{{ article.metadata.operator }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Park Content -->
|
||||
<div class="park-content prose max-w-none">
|
||||
{{ article.render|safe }}
|
||||
</div>
|
||||
|
||||
<!-- Featured Rides -->
|
||||
{% if article.related_articles.rides %}
|
||||
<div class="mt-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-4">Featured Rides</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{% for ride in article.related_articles.rides %}
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
{% if ride.image %}
|
||||
<img src="{{ ride.image.url }}" alt="{{ ride.title }}"
|
||||
class="w-full h-48 object-cover">
|
||||
{% endif %}
|
||||
<div class="p-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">
|
||||
<a href="{{ ride.get_absolute_url }}" class="hover:text-blue-600">
|
||||
{{ ride.title }}
|
||||
</a>
|
||||
</h3>
|
||||
<p class="text-gray-600 text-sm mt-2">
|
||||
{{ ride.description|truncatewords:30 }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Park Stats and Info -->
|
||||
{% if article.metadata.stats %}
|
||||
<div class="mt-8 bg-gray-50 rounded-lg p-6">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-4">Park Statistics</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{% for stat, value in article.metadata.stats.items %}
|
||||
<div class="stat-item">
|
||||
<span class="text-gray-600 font-medium">{{ stat|title }}:</span>
|
||||
<span class="text-gray-900">{{ value }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endblock %}
|
||||
|
||||
{% block wiki_sidebar %}
|
||||
{{ block.super }}
|
||||
<!-- Additional park-specific sidebar content -->
|
||||
<div class="mt-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-3">Quick Links</h3>
|
||||
<ul class="space-y-2">
|
||||
<li><a href="#rides" class="text-gray-600 hover:text-blue-600">Rides</a></li>
|
||||
<li><a href="#attractions" class="text-gray-600 hover:text-blue-600">Attractions</a></li>
|
||||
<li><a href="#dining" class="text-gray-600 hover:text-blue-600">Dining</a></li>
|
||||
<li><a href="#hotels" class="text-gray-600 hover:text-blue-600">Hotels</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
84
templates/wiki/plugins/parks/park_actions.html
Normal file
84
templates/wiki/plugins/parks/park_actions.html
Normal file
@@ -0,0 +1,84 @@
|
||||
{% load wiki_tags %}
|
||||
|
||||
{% if user.is_authenticated %}
|
||||
<div class="flex justify-end gap-2 mb-2">
|
||||
<!-- Wiki Article Actions -->
|
||||
{% if article|can_write:user %}
|
||||
<a href="{% url 'wiki:edit' article.id %}"
|
||||
class="transition-transform btn-secondary hover:scale-105">
|
||||
<i class="mr-1 fas fa-pencil-alt"></i>Edit Article
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<!-- Park Metadata Actions -->
|
||||
{% if park_metadata or article|can_write:user %}
|
||||
<a href="{% url 'wiki:parks_metadata' article.id %}"
|
||||
class="transition-transform btn-secondary hover:scale-105">
|
||||
<i class="mr-1 fas fa-info-circle"></i>Park Info
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<!-- Statistics Management -->
|
||||
{% if park_metadata and article|can_write:user %}
|
||||
<a href="{% url 'wiki:parks_statistics' article.id %}"
|
||||
class="transition-transform btn-secondary hover:scale-105">
|
||||
<i class="mr-1 fas fa-chart-bar"></i>Statistics
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<!-- Media Management -->
|
||||
{% if article|can_write:user %}
|
||||
<button class="transition-transform btn-secondary hover:scale-105"
|
||||
@click="$dispatch('show-wiki-media-upload')">
|
||||
<i class="mr-1 fas fa-camera"></i>Add Media
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<!-- Article Tools -->
|
||||
<div class="dropdown relative inline-block">
|
||||
<button class="transition-transform btn-secondary hover:scale-105">
|
||||
<i class="mr-1 fas fa-ellipsis-v"></i>More
|
||||
</button>
|
||||
<div class="dropdown-content hidden absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg">
|
||||
<!-- History -->
|
||||
<a href="{% url 'wiki:history' article.id %}"
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="mr-1 fas fa-history"></i>History
|
||||
</a>
|
||||
|
||||
<!-- Discussion -->
|
||||
<a href="{% url 'wiki:discussion' article.id %}"
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="mr-1 fas fa-comments"></i>Discussion
|
||||
</a>
|
||||
|
||||
<!-- Settings -->
|
||||
{% if article|can_moderate:user %}
|
||||
<a href="{% url 'wiki:settings' article.id %}"
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="mr-1 fas fa-cog"></i>Settings
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<!-- Permissions -->
|
||||
{% if article|can_moderate:user %}
|
||||
<a href="{% url 'wiki:permissions' article.id %}"
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="mr-1 fas fa-lock"></i>Permissions
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notification Area -->
|
||||
{% if messages %}
|
||||
<div class="mt-4">
|
||||
{% for message in messages %}
|
||||
<div class="p-4 mb-4 rounded-lg {% if message.tags == 'error' %}bg-red-100 text-red-700{% else %}bg-green-100 text-green-700{% endif %}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
200
templates/wiki/plugins/parks/park_metadata.html
Normal file
200
templates/wiki/plugins/parks/park_metadata.html
Normal file
@@ -0,0 +1,200 @@
|
||||
{% extends "wiki/article.html" %}
|
||||
{% load i18n %}
|
||||
{% load wiki_tags %}
|
||||
{% load static %}
|
||||
|
||||
{% block wiki_contents_tab %}
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 class="text-2xl font-bold mb-6">{% trans "Park Metadata" %}</h2>
|
||||
|
||||
<form method="POST" class="space-y-6">
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- Basic Information -->
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<h3 class="text-lg font-semibold mb-4">{% trans "Basic Information" %}</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-group">
|
||||
{{ form.operator.label_tag }}
|
||||
{{ form.operator }}
|
||||
{{ form.operator.errors }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{{ form.owner.label_tag }}
|
||||
{{ form.owner }}
|
||||
{{ form.owner.errors }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{{ form.opened_date.label_tag }}
|
||||
{{ form.opened_date }}
|
||||
{{ form.opened_date.errors }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{{ form.park_size.label_tag }}
|
||||
{{ form.park_size }}
|
||||
{{ form.park_size.errors }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location Information -->
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<h3 class="text-lg font-semibold mb-4">{% trans "Location" %}</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-group">
|
||||
{{ form.latitude.label_tag }}
|
||||
{{ form.latitude }}
|
||||
{{ form.latitude.errors }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{{ form.longitude.label_tag }}
|
||||
{{ form.longitude }}
|
||||
{{ form.longitude.errors }}
|
||||
</div>
|
||||
<div class="form-group col-span-2">
|
||||
{{ form.address.label_tag }}
|
||||
{{ form.address }}
|
||||
{{ form.address.errors }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Operating Information -->
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<h3 class="text-lg font-semibold mb-4">{% trans "Operating Information" %}</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="form-group">
|
||||
{{ form.seasonal.label_tag }}
|
||||
{{ form.seasonal }}
|
||||
{{ form.seasonal.errors }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{{ form.season_start.label_tag }}
|
||||
{{ form.season_start }}
|
||||
{{ form.season_start.errors }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{{ form.season_end.label_tag }}
|
||||
{{ form.season_end }}
|
||||
{{ form.season_end.errors }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Attractions -->
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<h3 class="text-lg font-semibold mb-4">{% trans "Attractions" %}</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-group">
|
||||
{{ form.total_rides.label_tag }}
|
||||
{{ form.total_rides }}
|
||||
{{ form.total_rides.errors }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{{ form.total_roller_coasters.label_tag }}
|
||||
{{ form.total_roller_coasters }}
|
||||
{{ form.total_roller_coasters.errors }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Information -->
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<h3 class="text-lg font-semibold mb-4">{% trans "Contact Information" %}</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-group">
|
||||
{{ form.phone.label_tag }}
|
||||
{{ form.phone }}
|
||||
{{ form.phone.errors }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{{ form.email.label_tag }}
|
||||
{{ form.email }}
|
||||
{{ form.email.errors }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{{ form.website.label_tag }}
|
||||
{{ form.website }}
|
||||
{{ form.website.errors }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Social Media -->
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<h3 class="text-lg font-semibold mb-4">{% trans "Social Media" %}</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="form-group">
|
||||
{{ form.facebook.label_tag }}
|
||||
{{ form.facebook }}
|
||||
{{ form.facebook.errors }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{{ form.twitter.label_tag }}
|
||||
{{ form.twitter }}
|
||||
{{ form.twitter.errors }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{{ form.instagram.label_tag }}
|
||||
{{ form.instagram }}
|
||||
{{ form.instagram.errors }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Information -->
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<h3 class="text-lg font-semibold mb-4">{% trans "Additional Information" %}</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="form-group">
|
||||
{{ form.amenities_text.label_tag }}
|
||||
{{ form.amenities_text }}
|
||||
{{ form.amenities_text.errors }}
|
||||
<p class="text-sm text-gray-600 mt-1">{{ form.amenities_text.help_text }}</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{{ form.ticket_info_text.label_tag }}
|
||||
{{ form.ticket_info_text }}
|
||||
{{ form.ticket_info_text.errors }}
|
||||
<p class="text-sm text-gray-600 mt-1">{{ form.ticket_info_text.help_text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="flex justify-end space-x-4">
|
||||
<a href="{% url 'wiki:get' path=article.get_absolute_url %}"
|
||||
class="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50">
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||
{% trans "Save Changes" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block wiki_footer_script %}
|
||||
{{ block.super }}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Handle seasonal checkbox toggling season dates
|
||||
const seasonalCheckbox = document.getElementById('id_seasonal');
|
||||
const seasonStartInput = document.getElementById('id_season_start');
|
||||
const seasonEndInput = document.getElementById('id_season_end');
|
||||
|
||||
function toggleSeasonDates() {
|
||||
const isDisabled = !seasonalCheckbox.checked;
|
||||
seasonStartInput.disabled = isDisabled;
|
||||
seasonEndInput.disabled = isDisabled;
|
||||
}
|
||||
|
||||
if (seasonalCheckbox) {
|
||||
toggleSeasonDates();
|
||||
seasonalCheckbox.addEventListener('change', toggleSeasonDates);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
146
templates/wiki/plugins/parks/park_statistics.html
Normal file
146
templates/wiki/plugins/parks/park_statistics.html
Normal file
@@ -0,0 +1,146 @@
|
||||
{% extends "wiki/article.html" %}
|
||||
{% load i18n %}
|
||||
{% load wiki_tags %}
|
||||
{% load static %}
|
||||
|
||||
{% block wiki_contents_tab %}
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 class="text-2xl font-bold mb-6">{% trans "Park Statistics" %}</h2>
|
||||
|
||||
<!-- Add New Statistics -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-lg font-semibold mb-4">{% trans "Add New Statistics" %}</h3>
|
||||
<form method="POST" class="bg-gray-50 p-4 rounded-lg">
|
||||
{% csrf_token %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div class="form-group">
|
||||
{{ form.year.label_tag }}
|
||||
{{ form.year }}
|
||||
{{ form.year.errors }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{{ form.attendance.label_tag }}
|
||||
{{ form.attendance }}
|
||||
{{ form.attendance.errors }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{{ form.revenue.label_tag }}
|
||||
{{ form.revenue }}
|
||||
{{ form.revenue.errors }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{{ form.investment.label_tag }}
|
||||
{{ form.investment }}
|
||||
{{ form.investment.errors }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<button type="submit"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||
{% trans "Add Statistics" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Statistics History -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-4">{% trans "Historical Statistics" %}</h3>
|
||||
{% if statistics %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{% trans "Year" %}
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{% trans "Attendance" %}
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{% trans "Revenue" %}
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{% trans "Investment" %}
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{% trans "Actions" %}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{% for stat in statistics %}
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{{ stat.year }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ stat.attendance|default:"-" }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{% if stat.revenue %}
|
||||
${{ stat.revenue }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{% if stat.investment %}
|
||||
${{ stat.investment }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<form method="POST" action="{% url 'wiki:parks_delete_statistic' article.id stat.id %}"
|
||||
class="inline-block">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
class="text-red-600 hover:text-red-900"
|
||||
onclick="return confirm('{% trans "Are you sure you want to delete this statistic?" %}')">
|
||||
{% trans "Delete" %}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-500 italic">{% trans "No statistics available." %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Back to Article -->
|
||||
<div class="mt-8">
|
||||
<a href="{% url 'wiki:get' path=article.get_absolute_url %}"
|
||||
class="inline-block px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50">
|
||||
{% trans "Back to Article" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block wiki_footer_script %}
|
||||
{{ block.super }}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Auto-fill current year if empty
|
||||
const yearInput = document.getElementById('id_year');
|
||||
if (yearInput && !yearInput.value) {
|
||||
yearInput.value = new Date().getFullYear();
|
||||
}
|
||||
|
||||
// Format number inputs
|
||||
const numberInputs = document.querySelectorAll('input[type="number"]');
|
||||
numberInputs.forEach(input => {
|
||||
input.addEventListener('blur', function() {
|
||||
if (this.value) {
|
||||
this.value = parseInt(this.value).toLocaleString();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
146
templates/wiki/plugins/parks/sidebar.html
Normal file
146
templates/wiki/plugins/parks/sidebar.html
Normal file
@@ -0,0 +1,146 @@
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
<div class="park-sidebar">
|
||||
<!-- Quick Stats -->
|
||||
<div class="bg-gray-50 p-4 rounded-lg mb-4">
|
||||
{% if article.park_metadata %}
|
||||
<div class="space-y-3">
|
||||
{% if article.park_metadata.operator %}
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-600">{% trans "Operator" %}</span>
|
||||
<span class="text-sm font-medium">{{ article.park_metadata.operator }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if article.park_metadata.opened_date %}
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-600">{% trans "Opened" %}</span>
|
||||
<span class="text-sm font-medium">{{ article.park_metadata.opened_date|date:"Y" }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if article.park_metadata.total_rides %}
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-600">{% trans "Total Rides" %}</span>
|
||||
<span class="text-sm font-medium">{{ article.park_metadata.total_rides }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if article.park_metadata.total_roller_coasters %}
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-600">{% trans "Roller Coasters" %}</span>
|
||||
<span class="text-sm font-medium">{{ article.park_metadata.total_roller_coasters }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if article.park_metadata.park_size %}
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-600">{% trans "Size" %}</span>
|
||||
<span class="text-sm font-medium">{{ article.park_metadata.park_size }} {% trans "acres" %}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Season Info -->
|
||||
{% if article.park_metadata.seasonal %}
|
||||
<div class="mt-4 pt-4 border-t border-gray-200">
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-2">{% trans "Season" %}</h4>
|
||||
<div class="text-sm text-gray-600">
|
||||
{% if article.park_metadata.season_start and article.park_metadata.season_end %}
|
||||
{{ article.park_metadata.season_start|date:"M j" }} - {{ article.park_metadata.season_end|date:"M j" }}
|
||||
{% else %}
|
||||
{% trans "Seasonal operation" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Contact -->
|
||||
{% if article.park_metadata.phone or article.park_metadata.email or article.park_metadata.website %}
|
||||
<div class="mt-4 pt-4 border-t border-gray-200">
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-2">{% trans "Contact" %}</h4>
|
||||
<div class="space-y-2">
|
||||
{% if article.park_metadata.phone %}
|
||||
<div class="text-sm">
|
||||
<a href="tel:{{ article.park_metadata.phone }}"
|
||||
class="text-blue-600 hover:text-blue-800">
|
||||
{{ article.park_metadata.phone }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if article.park_metadata.website %}
|
||||
<div class="text-sm">
|
||||
<a href="{{ article.park_metadata.website }}"
|
||||
class="text-blue-600 hover:text-blue-800"
|
||||
target="_blank" rel="noopener">
|
||||
{% trans "Official Website" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Social Media -->
|
||||
{% if article.park_metadata.facebook or article.park_metadata.twitter or article.park_metadata.instagram %}
|
||||
<div class="mt-4 pt-4 border-t border-gray-200">
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-2">{% trans "Social Media" %}</h4>
|
||||
<div class="flex space-x-4">
|
||||
{% if article.park_metadata.facebook %}
|
||||
<a href="{{ article.park_metadata.facebook }}"
|
||||
class="text-gray-400 hover:text-blue-600"
|
||||
target="_blank" rel="noopener">
|
||||
<i class="fab fa-facebook"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if article.park_metadata.twitter %}
|
||||
<a href="{{ article.park_metadata.twitter }}"
|
||||
class="text-gray-400 hover:text-blue-400"
|
||||
target="_blank" rel="noopener">
|
||||
<i class="fab fa-twitter"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if article.park_metadata.instagram %}
|
||||
<a href="{{ article.park_metadata.instagram }}"
|
||||
class="text-gray-400 hover:text-pink-600"
|
||||
target="_blank" rel="noopener">
|
||||
<i class="fab fa-instagram"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-500 italic">
|
||||
{% trans "No park metadata available." %}
|
||||
{% if article|can_write:user %}
|
||||
<a href="{% url 'wiki:parks_metadata' article.id %}"
|
||||
class="text-blue-600 hover:text-blue-800">
|
||||
{% trans "Add metadata" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Admin Actions -->
|
||||
{% if article|can_write:user %}
|
||||
<div class="space-y-2">
|
||||
<a href="{% url 'wiki:parks_metadata' article.id %}"
|
||||
class="block w-full px-4 py-2 text-sm text-center bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200">
|
||||
{% trans "Edit Park Information" %}
|
||||
</a>
|
||||
{% if article.park_metadata %}
|
||||
<a href="{% url 'wiki:parks_statistics' article.id %}"
|
||||
class="block w-full px-4 py-2 text-sm text-center bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200">
|
||||
{% trans "Manage Statistics" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
126
templates/wiki/rides/ride_article.html
Normal file
126
templates/wiki/rides/ride_article.html
Normal file
@@ -0,0 +1,126 @@
|
||||
{% extends "wiki/base.html" %}
|
||||
{% load wiki_tags %}
|
||||
{% load static %}
|
||||
|
||||
{% block wiki_header_title %}
|
||||
{{ article.current_revision.title }}
|
||||
{% endblock %}
|
||||
|
||||
{% block wiki_content %}
|
||||
<article class="ride-article">
|
||||
<!-- Ride Header -->
|
||||
<div class="mb-8">
|
||||
{% if article.image %}
|
||||
<div class="mb-4">
|
||||
<img src="{{ article.image.url }}" alt="{{ article.current_revision.title }}"
|
||||
class="w-full h-64 object-cover rounded-lg shadow-md">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Ride Quick Info -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 bg-gray-50 p-4 rounded-lg">
|
||||
{% if article.metadata.park %}
|
||||
<div class="ride-info-item">
|
||||
<span class="text-gray-600 font-medium">Park:</span>
|
||||
<a href="{{ article.metadata.park.get_absolute_url }}"
|
||||
class="text-blue-600 hover:text-blue-800">
|
||||
{{ article.metadata.park.name }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if article.metadata.opened %}
|
||||
<div class="ride-info-item">
|
||||
<span class="text-gray-600 font-medium">Opened:</span>
|
||||
<span class="text-gray-900">{{ article.metadata.opened }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if article.metadata.manufacturer %}
|
||||
<div class="ride-info-item">
|
||||
<span class="text-gray-600 font-medium">Manufacturer:</span>
|
||||
<span class="text-gray-900">{{ article.metadata.manufacturer }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if article.metadata.type %}
|
||||
<div class="ride-info-item">
|
||||
<span class="text-gray-600 font-medium">Type:</span>
|
||||
<span class="text-gray-900">{{ article.metadata.type }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ride Content -->
|
||||
<div class="ride-content prose max-w-none">
|
||||
{{ article.render|safe }}
|
||||
</div>
|
||||
|
||||
<!-- Technical Specifications -->
|
||||
{% if article.metadata.specs %}
|
||||
<div class="mt-8 bg-gray-50 rounded-lg p-6">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-4">Technical Specifications</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{% for spec, value in article.metadata.specs.items %}
|
||||
<div class="spec-item">
|
||||
<span class="text-gray-600 font-medium">{{ spec|title }}:</span>
|
||||
<span class="text-gray-900">{{ value }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Records and Statistics -->
|
||||
{% if article.metadata.records %}
|
||||
<div class="mt-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-4">Records & Achievements</h2>
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<ul class="space-y-3">
|
||||
{% for record in article.metadata.records %}
|
||||
<li class="flex items-start">
|
||||
<svg class="w-6 h-6 text-blue-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<span>{{ record }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endblock %}
|
||||
|
||||
{% block wiki_sidebar %}
|
||||
{{ block.super }}
|
||||
<!-- Additional ride-specific sidebar content -->
|
||||
<div class="mt-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-3">Quick Links</h3>
|
||||
<ul class="space-y-2">
|
||||
<li><a href="#specifications" class="text-gray-600 hover:text-blue-600">Specifications</a></li>
|
||||
<li><a href="#history" class="text-gray-600 hover:text-blue-600">History</a></li>
|
||||
<li><a href="#experience" class="text-gray-600 hover:text-blue-600">Ride Experience</a></li>
|
||||
<li><a href="#records" class="text-gray-600 hover:text-blue-600">Records</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Related Rides -->
|
||||
{% if article.related_articles.similar_rides %}
|
||||
<div class="mt-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-3">Similar Rides</h3>
|
||||
<ul class="space-y-2">
|
||||
{% for ride in article.related_articles.similar_rides %}
|
||||
<li>
|
||||
<a href="{{ ride.get_absolute_url }}"
|
||||
class="text-gray-600 hover:text-blue-600">
|
||||
{{ ride.title }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,228 +1,120 @@
|
||||
"""
|
||||
Django settings for thrillwiki project.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from django.conf import settings as django_settings
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
SECRET_KEY = "django-insecure-=0)^0#h#k$0@$8$ys=^$0#h#k$0@$8$ys=^"
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
# Quick-start development settings - unsuitable for production
|
||||
SECRET_KEY = 'django-insecure-key-for-development'
|
||||
DEBUG = True
|
||||
|
||||
CSRF_TRUSTED_ORIGINS = ["https://beta.thrillwiki.com"]
|
||||
ALLOWED_HOSTS = ["*"]
|
||||
|
||||
# GeoDjango Settings
|
||||
GDAL_LIBRARY_PATH = "/opt/homebrew/lib/libgdal.dylib"
|
||||
GEOS_LIBRARY_PATH = "/opt/homebrew/lib/libgeos_c.dylib"
|
||||
ALLOWED_HOSTS = []
|
||||
|
||||
# Application definition
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"django.contrib.sites",
|
||||
"django.contrib.gis", # Add GeoDjango
|
||||
"pghistory", # Add django-pghistory
|
||||
"pgtrigger", # Required by django-pghistory
|
||||
"history.apps.HistoryConfig", # History timeline app
|
||||
"allauth",
|
||||
"allauth.account",
|
||||
"allauth.socialaccount",
|
||||
"allauth.socialaccount.providers.google",
|
||||
"allauth.socialaccount.providers.discord",
|
||||
"django_cleanup",
|
||||
"django_filters",
|
||||
"django_htmx",
|
||||
"whitenoise",
|
||||
"django_tailwind_cli",
|
||||
"autocomplete", # Django HTMX Autocomplete
|
||||
"core",
|
||||
"accounts",
|
||||
"companies",
|
||||
"parks",
|
||||
"rides",
|
||||
"reviews",
|
||||
"email_service",
|
||||
"media.apps.MediaConfig",
|
||||
"moderation",
|
||||
"history_tracking",
|
||||
"designers",
|
||||
"analytics",
|
||||
"location",
|
||||
"search.apps.SearchConfig", # Add search app
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'django.contrib.sites.apps.SitesConfig',
|
||||
'django.contrib.humanize.apps.HumanizeConfig',
|
||||
'django_nyt.apps.DjangoNytConfig',
|
||||
'mptt',
|
||||
'sorl.thumbnail',
|
||||
'wiki.apps.WikiConfig', # Main wiki app
|
||||
'wiki.plugins.parks.apps.ParksPluginConfig', # Parks plugin
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.cache.UpdateCacheMiddleware",
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"core.middleware.PgHistoryContextMiddleware", # Add history context tracking
|
||||
"allauth.account.middleware.AccountMiddleware",
|
||||
"django.middleware.cache.FetchFromCacheMiddleware",
|
||||
"django_htmx.middleware.HtmxMiddleware",
|
||||
"analytics.middleware.PageViewMiddleware", # Add our page view tracking
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "thrillwiki.urls"
|
||||
ROOT_URLCONF = 'thrillwiki.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [os.path.join(BASE_DIR, "templates")],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
"moderation.context_processors.moderation_access",
|
||||
]
|
||||
}
|
||||
}
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [BASE_DIR / 'templates'],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.debug',
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
'django.template.context_processors.media',
|
||||
'django.template.context_processors.static',
|
||||
'django.template.context_processors.tz',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "thrillwiki.wsgi.application"
|
||||
WSGI_APPLICATION = 'thrillwiki.wsgi.application'
|
||||
|
||||
# Database
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.contrib.gis.db.backends.postgis", # Update to use PostGIS
|
||||
"NAME": "thrillwiki",
|
||||
"USER": "wiki",
|
||||
"PASSWORD": "thrillwiki",
|
||||
"HOST": "192.168.86.3",
|
||||
"PORT": "5432",
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.postgresql',
|
||||
'NAME': 'thrillwiki',
|
||||
'USER': 'postgres',
|
||||
'PASSWORD': 'postgres',
|
||||
'HOST': 'localhost',
|
||||
'PORT': '5432',
|
||||
}
|
||||
}
|
||||
|
||||
# Cache settings
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||
"LOCATION": "unique-snowflake",
|
||||
"TIMEOUT": 300, # 5 minutes
|
||||
"OPTIONS": {"MAX_ENTRIES": 1000},
|
||||
}
|
||||
}
|
||||
|
||||
CACHE_MIDDLEWARE_SECONDS = 1 # 5 minutes
|
||||
CACHE_MIDDLEWARE_KEY_PREFIX = "thrillwiki"
|
||||
|
||||
# Password validation
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
]
|
||||
|
||||
# Wiki settings
|
||||
WIKI_ACCOUNT_HANDLING = True
|
||||
WIKI_ACCOUNT_SIGNUP_ALLOWED = True
|
||||
WIKI_ANONYMOUS = True
|
||||
WIKI_ANONYMOUS_WRITE = False
|
||||
WIKI_MARKDOWN_HTML_ATTRIBUTES = True
|
||||
WIKI_MARKDOWN_HTML_STYLES = True
|
||||
|
||||
SITE_ID = 1
|
||||
|
||||
# Internationalization
|
||||
LANGUAGE_CODE = "en-us"
|
||||
TIME_ZONE = "America/New_York"
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
TIME_ZONE = 'UTC'
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
# Static files (CSS JavaScript Images)
|
||||
STATIC_URL = "static/"
|
||||
STATICFILES_DIRS = [BASE_DIR / "static"]
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
|
||||
|
||||
# Media files
|
||||
MEDIA_URL = "/media/"
|
||||
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
|
||||
|
||||
# Default primary key field type
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
# Authentication settings
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
"django.contrib.auth.backends.ModelBackend",
|
||||
"allauth.account.auth_backends.AuthenticationBackend",
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
STATIC_URL = 'static/'
|
||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||
STATICFILES_DIRS = [
|
||||
BASE_DIR / 'static',
|
||||
]
|
||||
|
||||
# django-allauth settings
|
||||
SITE_ID = 1
|
||||
ACCOUNT_EMAIL_REQUIRED = True
|
||||
ACCOUNT_USERNAME_REQUIRED = True
|
||||
ACCOUNT_LOGIN_METHODS = {'email', 'username'}
|
||||
ACCOUNT_EMAIL_VERIFICATION = "optional"
|
||||
LOGIN_REDIRECT_URL = "/"
|
||||
ACCOUNT_LOGOUT_REDIRECT_URL = "/"
|
||||
# Media files
|
||||
MEDIA_URL = '/media/'
|
||||
MEDIA_ROOT = BASE_DIR / 'uploads'
|
||||
|
||||
# Custom adapters
|
||||
ACCOUNT_ADAPTER = "accounts.adapters.CustomAccountAdapter"
|
||||
SOCIALACCOUNT_ADAPTER = "accounts.adapters.CustomSocialAccountAdapter"
|
||||
|
||||
# Social account settings
|
||||
SOCIALACCOUNT_PROVIDERS = {
|
||||
"google": {
|
||||
"APP": {
|
||||
"client_id": "135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com",
|
||||
"[SECRET-REMOVED]",
|
||||
"key": "",
|
||||
},
|
||||
"SCOPE": [
|
||||
"profile",
|
||||
"email",
|
||||
],
|
||||
"AUTH_PARAMS": {"access_type": "online"},
|
||||
},
|
||||
"discord": {
|
||||
"APP": {
|
||||
"client_id": "1299112802274902047",
|
||||
"[SECRET-REMOVED]",
|
||||
"key": "",
|
||||
},
|
||||
"SCOPE": ["identify", "email"],
|
||||
"OAUTH_PKCE_ENABLED": True,
|
||||
}
|
||||
}
|
||||
|
||||
# Additional social account settings
|
||||
SOCIALACCOUNT_LOGIN_ON_GET = True
|
||||
SOCIALACCOUNT_AUTO_SIGNUP = False
|
||||
SOCIALACCOUNT_STORE_TOKENS = True
|
||||
# Default primary key field type
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
# Email settings
|
||||
EMAIL_BACKEND = "email_service.backends.ForwardEmailBackend"
|
||||
FORWARD_EMAIL_BASE_URL = "https://api.forwardemail.net"
|
||||
SERVER_EMAIL = "django_webmaster@thrillwiki.com"
|
||||
# Custom User Model
|
||||
AUTH_USER_MODEL = "accounts.User"
|
||||
|
||||
# Autocomplete configuration
|
||||
# Enable project-wide authentication requirement for autocomplete
|
||||
AUTOCOMPLETE_BLOCK_UNAUTHENTICATED = False
|
||||
|
||||
# Tailwind configuration
|
||||
# Tailwind configuration
|
||||
TAILWIND_CLI_CONFIG_FILE = os.path.join(BASE_DIR, "tailwind.config.js")
|
||||
TAILWIND_CLI_SRC_CSS = os.path.join(BASE_DIR, "static/css/src/input.css")
|
||||
TAILWIND_CLI_DIST_CSS = os.path.join(BASE_DIR, "static/css/tailwind.css")
|
||||
|
||||
# Cloudflare Turnstile settings
|
||||
TURNSTILE_SITE_KEY = "0x4AAAAAAAyqVp3RjccrC9Kz"
|
||||
TURNSTILE_SECRET_KEY = "0x4AAAAAAAyqVrQolYsrAFGJ39PXHJ_HQzY"
|
||||
TURNSTILE_VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||
|
||||
@@ -1,82 +1,6 @@
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.views.static import serve
|
||||
from accounts import views as accounts_views
|
||||
from django.views.generic import TemplateView
|
||||
from .views import HomeView, SearchView
|
||||
from . import views
|
||||
import os
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
# Main app URLs
|
||||
path("", HomeView.as_view(), name="home"),
|
||||
# Parks and Rides URLs
|
||||
path("parks/", include("parks.urls", namespace="parks")),
|
||||
# Global rides URLs
|
||||
path("rides/", include("rides.urls", namespace="rides")),
|
||||
# Other URLs
|
||||
path("reviews/", include("reviews.urls")),
|
||||
path("companies/", include("companies.urls")),
|
||||
path("designers/", include("designers.urls", namespace="designers")),
|
||||
path("photos/", include("media.urls", namespace="photos")), # Add photos URLs
|
||||
path("search/", SearchView.as_view(), name="search"),
|
||||
path(
|
||||
"terms/", TemplateView.as_view(template_name="pages/terms.html"), name="terms"
|
||||
),
|
||||
path(
|
||||
"privacy/",
|
||||
TemplateView.as_view(template_name="pages/privacy.html"),
|
||||
name="privacy",
|
||||
),
|
||||
# Custom authentication URLs first (to override allauth defaults)
|
||||
path("accounts/", include("accounts.urls")),
|
||||
# Default allauth URLs (for social auth and other features)
|
||||
path("accounts/", include("allauth.urls")),
|
||||
path(
|
||||
"accounts/email-required/", accounts_views.email_required, name="email_required"
|
||||
),
|
||||
# User profile URLs
|
||||
path(
|
||||
"user/<str:username>/",
|
||||
accounts_views.ProfileView.as_view(),
|
||||
name="user_profile",
|
||||
),
|
||||
path(
|
||||
"profile/<str:username>/", accounts_views.ProfileView.as_view(), name="profile"
|
||||
),
|
||||
path("settings/", accounts_views.SettingsView.as_view(), name="settings"),
|
||||
# Redirect /user/ to the user's profile if logged in
|
||||
path("user/", accounts_views.user_redirect_view, name="user_redirect"),
|
||||
# Moderation URLs - placed after other URLs but before static/media serving
|
||||
path("moderation/", include("moderation.urls", namespace="moderation")),
|
||||
path("history/", include("history.urls", namespace="history")),
|
||||
path(
|
||||
"env-settings/",
|
||||
views***REMOVED***ironment_and_settings_view,
|
||||
name="environment_and_settings",
|
||||
),
|
||||
path('admin/', admin.site.urls),
|
||||
]
|
||||
|
||||
# Serve static files in development
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
|
||||
# Serve test coverage reports in development
|
||||
coverage_dir = os.path.join(settings.BASE_DIR, 'tests', 'coverage_html')
|
||||
if os.path.exists(coverage_dir):
|
||||
urlpatterns += [
|
||||
path('coverage/', serve, {
|
||||
'document_root': coverage_dir,
|
||||
'path': 'index.html'
|
||||
}),
|
||||
path('coverage/<path:path>', serve, {
|
||||
'document_root': coverage_dir,
|
||||
}),
|
||||
]
|
||||
|
||||
handler404 = "thrillwiki.views.handler404"
|
||||
handler500 = "thrillwiki.views.handler500"
|
||||
|
||||
175
uv.lock
generated
175
uv.lock
generated
@@ -64,6 +64,23 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]black-24.10.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]9853e47a294a3dd963c1dd7d", size = 206898 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bleach"
|
||||
version = "6.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "webencodings" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]bleach-6.2.0.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]b1317a7e1ba69b56e95f991f", size = 203083 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]bleach-6.2.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]36f554da4e432fdd63f31e5e", size = 163406 },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
css = [
|
||||
{ name = "tinycss2" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2024.12.14"
|
||||
@@ -267,6 +284,18 @@ dependencies = [
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_allauth-65.3.0.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]a86d873a8a9fd8f0ec57bbbf", size = 1546784 }
|
||||
|
||||
[[package]]
|
||||
name = "django-classy-tags"
|
||||
version = "4.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django-classy-tags-4.1.0.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]bb750b2490a17b161774ee59", size = 24692 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_classy_tags-4.1.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]a160a847ff449588d4e01e55", size = 14095 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-cleanup"
|
||||
version = "9.0.0"
|
||||
@@ -326,6 +355,42 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_htmx_autocomplete-1.0.5-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]3572e8742fe5dfa848298735", size = 52127 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-js-asset"
|
||||
version = "3.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_js_asset-3.0.1.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]2f6a6bfe93577dee793dc378", size = 7701 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_js_asset-3.0.1-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]43c282cb64fe6c13d7ca4c10", size = 4283 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-mptt"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django-js-asset" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_mptt-0.16.0.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]999b10903b09de62bee84c8e", size = 69886 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_mptt-0.16.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]5f8472b690dbaf737d2af3b5", size = 115934 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-nyt"
|
||||
version = "1.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_nyt-1.4.1.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]1d987ee81bf6a0ac3352b4a1", size = 28960 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_nyt-1.4.1-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]47cdb2cc10e7c4f2fecd6aff", size = 41084 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-oauth-toolkit"
|
||||
version = "3.0.1"
|
||||
@@ -366,6 +431,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_pgtrigger-4.13.3-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]275d86ad756b90c307df3ca4", size = 34059 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-sekizai"
|
||||
version = "4.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "django-classy-tags" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django-sekizai-4.1.0.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]3145bff11e58622fc653cdad", size = 14591 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_sekizai-4.1.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]9a1304a9b9e8b191229e2e4a", size = 8597 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-simple-history"
|
||||
version = "3.7.0"
|
||||
@@ -521,6 +599,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]jwcrypto-1.5.6-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]8be71f67f03566692fd55789", size = 92520 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown"
|
||||
version = "3.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]Markdown-3.6.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]a16cb35fa8ed8c2ddfad0224", size = 354715 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]Markdown-3.6-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]d40aa410fbc3b4ee832c850f", size = 105381 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mccabe"
|
||||
version = "0.7.0"
|
||||
@@ -745,6 +832,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]44f25406ffaebd50bd98dacb", size = 22997 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pymdown-extensions"
|
||||
version = "10.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown" },
|
||||
{ name = "pyyaml" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]pymdown_extensions-10.5.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]a294de1989f29d20096cfd0b", size = 788318 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]pymdown_extensions-10.5-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]6ac4c5eb01e27464b80fe879", size = 241130 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyopenssl"
|
||||
version = "24.3.0"
|
||||
@@ -833,6 +933,23 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]5fd9e3a70164fc8c50faa6b8", size = 10051 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]pyyaml-6.0.2.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]e591037abe114850ff7bbc3e", size = 130631 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:[AWS-SECRET-REMOVED]c3170801852d752aa7a783ba", size = 181309 },
|
||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:[AWS-SECRET-REMOVED]997de9efef88badc3bb9e2d1", size = 171679 },
|
||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:[AWS-SECRET-REMOVED]0eef8c8f44e0254ab3b07133", size = 733428 },
|
||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:[AWS-SECRET-REMOVED]73d41e99c4fff6b6c3276484", size = 763361 },
|
||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:[AWS-SECRET-REMOVED]f1e08d9b561cb41b845f69d5", size = 759523 },
|
||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:[AWS-SECRET-REMOVED]34e29c2a514c2c0c5fe971cc", size = 726660 },
|
||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:[AWS-SECRET-REMOVED]899c72eacb5a668902e4d652", size = 751597 },
|
||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:[AWS-SECRET-REMOVED]36abab80d4681424b84c1183", size = 140527 },
|
||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:[AWS-SECRET-REMOVED]dd57cdeb95f3f2e085687563", size = 156446 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "5.2.1"
|
||||
@@ -890,6 +1007,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]56c89140852d1120324e8686", size = 9755 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sorl-thumbnail"
|
||||
version = "12.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]sorl_thumbnail-12.11.0.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]f439b2e17b938b91eea463b3", size = 667102 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]sorl_thumbnail-12.11.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]4af5e9dc3f31cb605df765b5", size = 42789 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlparse"
|
||||
version = "0.5.3"
|
||||
@@ -926,6 +1052,8 @@ dependencies = [
|
||||
{ name = "django-filter" },
|
||||
{ name = "django-htmx" },
|
||||
{ name = "django-htmx-autocomplete" },
|
||||
{ name = "django-mptt" },
|
||||
{ name = "django-nyt" },
|
||||
{ name = "django-oauth-toolkit" },
|
||||
{ name = "django-pghistory" },
|
||||
{ name = "django-simple-history" },
|
||||
@@ -943,7 +1071,9 @@ dependencies = [
|
||||
{ name = "pytest-playwright" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "requests" },
|
||||
{ name = "sorl-thumbnail" },
|
||||
{ name = "whitenoise" },
|
||||
{ name = "wiki" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
@@ -961,6 +1091,8 @@ requires-dist = [
|
||||
{ name = "django-filter", specifier = ">=23.5" },
|
||||
{ name = "django-htmx", specifier = ">=1.17.2" },
|
||||
{ name = "django-htmx-autocomplete", specifier = ">=1.0.5" },
|
||||
{ name = "django-mptt", specifier = ">=0.16.0" },
|
||||
{ name = "django-nyt", specifier = ">=1.4.1" },
|
||||
{ name = "django-oauth-toolkit", specifier = ">=3.0.1" },
|
||||
{ name = "django-pghistory", specifier = ">=3.5.2" },
|
||||
{ name = "django-simple-history", specifier = ">=3.5.0" },
|
||||
@@ -978,7 +1110,21 @@ requires-dist = [
|
||||
{ name = "pytest-playwright", specifier = ">=0.4.3" },
|
||||
{ name = "python-dotenv", specifier = ">=1.0.1" },
|
||||
{ name = "requests", specifier = ">=2.32.3" },
|
||||
{ name = "sorl-thumbnail", specifier = ">=12.11.0" },
|
||||
{ name = "whitenoise", specifier = ">=6.6.0" },
|
||||
{ name = "wiki", specifier = ">=0.11.2" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinycss2"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "webencodings" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]tinycss2-1.4.0.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]c1ae10ebccdea16fb404a9b7", size = 87085 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]4bb905a5775adb0d884c5289", size = 26610 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1055,6 +1201,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]urllib3-2.3.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]710050facf0dd6911440e3df", size = 128369 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webencodings"
|
||||
version = "0.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]webencodings-0.5.1.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]865afcc4aab16748587e1923", size = 9721 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]3f95be16fc9acd2947514a78", size = 11774 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "whitenoise"
|
||||
version = "6.8.2"
|
||||
@@ -1064,6 +1219,26 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]whitenoise-6.8.2-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]ab9726e5772ac50fb45d2280", size = 20158 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wiki"
|
||||
version = "0.11.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "bleach", extra = ["css"] },
|
||||
{ name = "django" },
|
||||
{ name = "django-mptt" },
|
||||
{ name = "django-nyt" },
|
||||
{ name = "django-sekizai" },
|
||||
{ name = "markdown" },
|
||||
{ name = "pillow" },
|
||||
{ name = "pymdown-extensions" },
|
||||
{ name = "sorl-thumbnail" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]wiki-0.11.2.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]4140b4c9c64497736c1594d7", size = 2274191 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]wiki-0.11.2-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]a1182bd105f2cbfebbeb20aa", size = 2436316 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zope-interface"
|
||||
version = "7.2"
|
||||
|
||||
1
wiki/__init__.py
Normal file
1
wiki/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
default_app_config = "wiki.apps.WikiConfig"
|
||||
11
wiki/apps.py
Normal file
11
wiki/apps.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
class WikiConfig(AppConfig):
|
||||
name = 'wiki'
|
||||
verbose_name = 'Wiki'
|
||||
|
||||
def ready(self):
|
||||
"""
|
||||
Register signals and perform other initialization
|
||||
"""
|
||||
pass
|
||||
1
wiki/plugins/parks/__init__.py
Normal file
1
wiki/plugins/parks/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
default_app_config = "wiki.plugins.parks.apps.ParksPluginConfig"
|
||||
13
wiki/plugins/parks/apps.py
Normal file
13
wiki/plugins/parks/apps.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
class ParksPluginConfig(AppConfig):
|
||||
name = "wiki.plugins.parks"
|
||||
label = "wiki_parks"
|
||||
verbose_name = "Wiki Parks Plugin"
|
||||
|
||||
def ready(self):
|
||||
"""
|
||||
Register plugin with wiki system when the app is ready.
|
||||
Plugin registration is deferred until wiki core is available.
|
||||
"""
|
||||
pass
|
||||
34
wiki/plugins/parks/models.py
Normal file
34
wiki/plugins/parks/models.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
class ParkMetadata(models.Model):
|
||||
article = models.OneToOneField(
|
||||
'wiki.Article', # Using string reference to avoid import issues
|
||||
on_delete=models.CASCADE,
|
||||
related_name='park_metadata'
|
||||
)
|
||||
|
||||
operator = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_('Operator'),
|
||||
blank=True
|
||||
)
|
||||
|
||||
opened_date = models.DateField(
|
||||
verbose_name=_('Opening Date'),
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
|
||||
location = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_('Location'),
|
||||
blank=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Park Metadata')
|
||||
verbose_name_plural = _('Park Metadata')
|
||||
|
||||
def __str__(self):
|
||||
return f"Park info for {self.article.current_revision.title}"
|
||||
14
wiki/plugins/parks/wiki_plugin.py
Normal file
14
wiki/plugins/parks/wiki_plugin.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
class ParksPlugin:
|
||||
"""
|
||||
Plugin for handling parks in the wiki system.
|
||||
Core registration will be added later.
|
||||
"""
|
||||
slug = 'parks'
|
||||
|
||||
sidebar = {
|
||||
'headline': _('Park Information'),
|
||||
'icon_class': 'fa-info-circle',
|
||||
'template': 'wiki/plugins/parks/sidebar.html',
|
||||
}
|
||||
Reference in New Issue
Block a user