feat: major API restructure and Vue.js frontend integration

- Centralize API endpoints in dedicated api app with v1 versioning
- Remove individual API modules from parks and rides apps
- Add event tracking system with analytics functionality
- Integrate Vue.js frontend with Tailwind CSS v4 and TypeScript
- Add comprehensive database migrations for event tracking
- Implement user authentication and social provider setup
- Add API schema documentation and serializers
- Configure development environment with shared scripts
- Update project structure for monorepo with frontend/backend separation
This commit is contained in:
pacnpal
2025-08-24 16:42:20 -04:00
parent 92f4104d7a
commit e62646bcf9
127 changed files with 27734 additions and 1867 deletions

View File

@@ -3,10 +3,13 @@
## Development Server
IMPORTANT: Always follow these instructions exactly when starting the development server:
```bash
lsof -ti :8000 | xargs kill -9; find . -type d -name "__pycache__" -exec rm -r {} +; ./scripts/dev_server.sh
FIRST, assume the server is running. Always. Assume the changes have taken effect.
Note: These steps must be executed in this exact order as a single command to ensure consistent behavior. If server does not start correctly, do not attempt to modify the dev_server.sh script.
IF THERE IS AN ISSUE WITH THE SERVER, run the following command exactly:
```bash
lsof -ti :8000 | xargs kill -9; find . -type d -name "__pycache__" -exec rm -r {} +; cd backend && uv run manage.py runserver_plus && cd ../frontend && pnpm run dev
Note: These steps must be executed in this exact order to ensure consistent behavior. If server does not start correctly, fix the error in accordance with the error details as best you can.
## Package Management
IMPORTANT: When a Python package is needed, only use UV to add it:
@@ -47,8 +50,30 @@ IMPORTANT: Follow these entity relationship patterns consistently:
- PropertyOwners: Companies that own park property (new concept, optional)
- Manufacturers: Companies that manufacture rides (replaces Company for rides)
- Designers: Companies/individuals that design rides (existing concept)
- IMPORTANT: All entities can have locations.
# Relationship Constraints
- Operator and PropertyOwner are usually the same entity but CAN be different
- Manufacturers and Designers are distinct concepts and should not be conflated
- All entity relationships should use proper foreign keys with appropriate null/blank settings
- You are to NEVER assume that blank output means your fixes were correct. That assumption can lead to further issues down the line.
- ALWAYS verify your changes by testing the affected functionality thoroughly.
- ALWAYS use context7 to check documentation when troubleshooting. It contains VITAL documentation for any and all frameworks, modules, and packages.
- ALWAYS document your code changes with conport and the reasoning behind them.
- ALWAYS include relevant context and information when making changes to the codebase.
- ALWAYS ensure that your code changes are properly tested and validated before deployment.
- ALWAYS communicate clearly and effectively with your team about any changes you make.
- ALWAYS be open to feedback and willing to make adjustments as necessary.
- ALWAYS strive for continuous improvement in your work and processes.
- ALWAYS prioritize code readability and maintainability.
- ALWAYS keep security best practices in mind when developing and reviewing code.
- ALWAYS consider performance implications when making changes to the codebase.
- ALWAYS be mindful of the impact of your changes on the overall system architecture.
- ALWAYS keep scalability in mind when designing new features or modifying existing ones.
- ALWAYS consider the potential for code reuse and modularity in your designs.
- ALWAYS document your code with clear and concise comments.
- ALWAYS keep your code DRY (Don't Repeat Yourself) by abstracting common functionality into reusable components.
- ALWAYS use meaningful variable and function names to improve code readability.
- ALWAYS handle errors and exceptions gracefully to improve the user experience.
- ALWAYS log important events and errors for troubleshooting purposes.

304
README.md
View File

@@ -1,16 +1,29 @@
# ThrillWiki Django + Vue.js Monorepo
A modern monorepo architecture for ThrillWiki, combining a Django REST API backend with a Vue.js frontend.
A comprehensive theme park and roller coaster information system built with a modern monorepo architecture combining Django REST API backend with Vue.js frontend.
## 🏗️ Architecture
## 🏗️ Architecture Overview
This project uses a monorepo structure that cleanly separates backend and frontend concerns:
This project uses a monorepo structure that cleanly separates backend and frontend concerns while maintaining shared resources and documentation:
```
thrillwiki-monorepo/
├── backend/ # Django REST API
├── frontend/ # Vue.js SPA
└── shared/ # Shared resources and documentation
├── backend/ # Django REST API (Port 8000)
│ ├── apps/ # Modular Django applications
│ ├── config/ # Django settings and configuration
│ ├── templates/ # Django templates
│ └── static/ # Static assets
├── frontend/ # Vue.js SPA (Port 5174)
│ ├── src/ # Vue.js source code
│ ├── public/ # Static assets
│ └── dist/ # Build output
├── shared/ # Shared resources and documentation
│ ├── docs/ # Comprehensive documentation
│ ├── scripts/ # Development and deployment scripts
│ ├── config/ # Shared configuration
│ └── media/ # Shared media files
├── architecture/ # Architecture documentation
└── profiles/ # Development profiles
```
## 🚀 Quick Start
@@ -19,6 +32,8 @@ thrillwiki-monorepo/
- **Python 3.11+** with [uv](https://docs.astral.sh/uv/) for backend dependencies
- **Node.js 18+** with [pnpm](https://pnpm.io/) for frontend dependencies
- **PostgreSQL 14+** (optional, defaults to SQLite for development)
- **Redis 6+** (optional, for caching and sessions)
### Development Setup
@@ -34,39 +49,61 @@ thrillwiki-monorepo/
pnpm install
# Install backend dependencies
cd backend && uv sync
cd backend && uv sync && cd ..
```
3. **Start development servers**
3. **Environment configuration**
```bash
# Start both frontend and backend
# Copy environment files
cp .env.example .env
cp backend/.env.example backend/.env
cp frontend/.env.development frontend/.env.local
# Edit .env files with your settings
```
4. **Database setup**
```bash
cd backend
uv run manage.py migrate
uv run manage.py createsuperuser
cd ..
```
5. **Start development servers**
```bash
# Start both servers concurrently
pnpm run dev
# Or start individually
pnpm run dev:frontend # Vue.js on :3000
pnpm run dev:frontend # Vue.js on :5174
pnpm run dev:backend # Django on :8000
```
## 📁 Project Structure
## 📁 Project Structure Details
### Backend (`/backend`)
- **Django REST API** with modular app architecture
- **UV package management** for Python dependencies
- **PostgreSQL** database (configurable)
- **Redis** for caching and sessions
- **Django 5.0+** with REST Framework for API development
- **Modular app architecture** with separate apps for parks, rides, accounts, etc.
- **UV package management** for fast, reliable Python dependency management
- **PostgreSQL/SQLite** database with comprehensive entity relationships
- **Redis** for caching, sessions, and background tasks
- **Comprehensive API** with frontend serializers for camelCase conversion
### Frontend (`/frontend`)
- **Vue 3** with Composition API
- **TypeScript** for type safety
- **Vite** for fast development and building
- **Tailwind CSS** for styling
- **Pinia** for state management
- **Vue 3** with Composition API and `<script setup>` syntax
- **TypeScript** for type safety and better developer experience
- **Vite** for lightning-fast development and optimized production builds
- **Tailwind CSS** with custom design system and dark mode support
- **Pinia** for state management with modular stores
- **Vue Router** for client-side routing
- **Comprehensive UI component library** with shadcn-vue components
### Shared (`/shared`)
- Documentation and deployment guides
- Shared TypeScript types
- Build and deployment scripts
- Docker configurations
### Shared Resources (`/shared`)
- **Documentation** - Comprehensive guides and API documentation
- **Development scripts** - Automated setup, build, and deployment scripts
- **Configuration** - Shared Docker, CI/CD, and infrastructure configs
- **Media management** - Centralized media file handling and optimization
## 🛠️ Development Workflow
@@ -74,77 +111,234 @@ thrillwiki-monorepo/
```bash
# Development
pnpm run dev # Start both servers
pnpm run dev:frontend # Frontend only
pnpm run dev:backend # Backend only
pnpm run dev # Start both servers concurrently
pnpm run dev:frontend # Frontend only (:5174)
pnpm run dev:backend # Backend only (:8000)
# Building
pnpm run build # Build for production
pnpm run build:frontend # Frontend build only
pnpm run build # Build frontend for production
pnpm run build:staging # Build for staging environment
pnpm run build:production # Build for production environment
# Testing
pnpm run test # Run all tests
pnpm run test:frontend # Frontend tests
pnpm run test:backend # Backend tests
pnpm run test:frontend # Frontend unit and E2E tests
pnpm run test:backend # Backend unit and integration tests
# Code Quality
pnpm run lint # Lint all code
pnpm run format # Format all code
pnpm run type-check # TypeScript type checking
# Setup and Maintenance
pnpm run install:all # Install all dependencies
./shared/scripts/dev/setup-dev.sh # Full development setup
./shared/scripts/dev/start-all.sh # Start all services
```
### Backend Commands
### Backend Development
```bash
cd backend
# Django management
# Django management commands
uv run manage.py migrate
uv run manage.py makemigrations
uv run manage.py createsuperuser
uv run manage.py collectstatic
# Testing
# Testing and quality
uv run manage.py test
uv run black . # Format code
uv run flake8 . # Lint code
uv run isort . # Sort imports
```
### Frontend Development
```bash
cd frontend
# Vue.js development
pnpm run dev # Start dev server
pnpm run build # Production build
pnpm run preview # Preview production build
pnpm run test:unit # Vitest unit tests
pnpm run test:e2e # Playwright E2E tests
pnpm run lint # ESLint
pnpm run type-check # TypeScript checking
```
## 🔧 Configuration
### Environment Variables
Create `.env` files for local development:
#### Root `.env`
```bash
# Root .env (shared settings)
# Database
DATABASE_URL=postgresql://user:pass@localhost/thrillwiki
REDIS_URL=redis://localhost:6379
SECRET_KEY=your-secret-key
# Backend .env
DJANGO_SETTINGS_MODULE=config.django.local
# Security
SECRET_KEY=your-secret-key
DEBUG=True
# Frontend .env
VITE_API_BASE_URL=http://localhost:8000/api
# API Configuration
API_BASE_URL=http://localhost:8000/api
```
#### Backend `.env`
```bash
# Django Settings
DJANGO_SETTINGS_MODULE=config.django.local
DEBUG=True
ALLOWED_HOSTS=localhost,127.0.0.1
# Database
DATABASE_URL=postgresql://user:pass@localhost/thrillwiki
# Redis
REDIS_URL=redis://localhost:6379
# Email (optional)
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
EMAIL_USE_TLS=True
```
#### Frontend `.env.local`
```bash
# API Configuration
VITE_API_BASE_URL=http://localhost:8000/api
# Development
VITE_APP_TITLE=ThrillWiki (Development)
# Feature Flags
VITE_ENABLE_DEBUG=true
```
## 📊 Key Features
### Backend Features
- **Comprehensive Park Database** - Detailed information about theme parks worldwide
- **Extensive Ride Database** - Complete roller coaster and ride information
- **User Management** - Authentication, profiles, and permissions
- **Content Moderation** - Review and approval workflows
- **API Documentation** - Auto-generated OpenAPI/Swagger docs
- **Background Tasks** - Celery integration for long-running processes
- **Caching Strategy** - Redis-based caching for performance
- **Search Functionality** - Full-text search across all content
### Frontend Features
- **Responsive Design** - Mobile-first approach with Tailwind CSS
- **Dark Mode Support** - Complete dark/light theme system
- **Real-time Search** - Instant search with debouncing and highlighting
- **Interactive Maps** - Park and ride location visualization
- **Photo Galleries** - High-quality image management
- **User Dashboard** - Personalized content and contributions
- **Progressive Web App** - PWA capabilities for mobile experience
- **Accessibility** - WCAG 2.1 AA compliance
## 📖 Documentation
- [Backend Documentation](./backend/README.md)
- [Frontend Documentation](./frontend/README.md)
- [Deployment Guide](./shared/docs/deployment/)
- [API Documentation](./shared/docs/api/)
### Core Documentation
- **[Backend Documentation](./backend/README.md)** - Django setup and API details
- **[Frontend Documentation](./frontend/README.md)** - Vue.js setup and development
- **[API Documentation](./shared/docs/api/README.md)** - Complete API reference
- **[Development Workflow](./shared/docs/development/workflow.md)** - Daily development processes
### Architecture & Deployment
- **[Architecture Overview](./architecture/)** - System design and decisions
- **[Deployment Guide](./shared/docs/deployment/)** - Production deployment instructions
- **[Development Scripts](./shared/scripts/)** - Automation and tooling
### Additional Resources
- **[Contributing Guide](./CONTRIBUTING.md)** - How to contribute to the project
- **[Code of Conduct](./CODE_OF_CONDUCT.md)** - Community guidelines
- **[Security Policy](./SECURITY.md)** - Security reporting and policies
## 🚀 Deployment
See [Deployment Guide](./shared/docs/deployment/) for production setup instructions.
### Development Environment
```bash
# Quick start with all services
./shared/scripts/dev/start-all.sh
# Full development setup
./shared/scripts/dev/setup-dev.sh
```
### Production Deployment
```bash
# Build all components
./shared/scripts/build/build-all.sh
# Deploy to production
./shared/scripts/deploy/deploy.sh
```
See [Deployment Guide](./shared/docs/deployment/) for detailed production setup instructions.
## 🧪 Testing Strategy
### Backend Testing
- **Unit Tests** - Individual function and method testing
- **Integration Tests** - API endpoint and database interaction testing
- **E2E Tests** - Full user journey testing with Selenium
### Frontend Testing
- **Unit Tests** - Component and utility function testing with Vitest
- **Integration Tests** - Component interaction testing
- **E2E Tests** - User journey testing with Playwright
### Code Quality
- **Linting** - ESLint for JavaScript/TypeScript, Flake8 for Python
- **Type Checking** - TypeScript for frontend, mypy for Python
- **Code Formatting** - Prettier for frontend, Black for Python
## 🤝 Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Run tests and linting
5. Submit a pull request
We welcome contributions! Please see our [Contributing Guide](./CONTRIBUTING.md) for details on:
1. **Development Setup** - Getting your development environment ready
2. **Code Standards** - Coding conventions and best practices
3. **Pull Request Process** - How to submit your changes
4. **Issue Reporting** - How to report bugs and request features
### Quick Contribution Start
```bash
# Fork and clone the repository
git clone https://github.com/your-username/thrillwiki-monorepo.git
cd thrillwiki-monorepo
# Set up development environment
./shared/scripts/dev/setup-dev.sh
# Create a feature branch
git checkout -b feature/your-feature-name
# Make your changes and test
pnpm run test
# Submit a pull request
```
## 📄 License
This project is licensed under the MIT License.
This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details.
## 🙏 Acknowledgments
- **Theme Park Community** - For providing data and inspiration
- **Open Source Contributors** - For the amazing tools and libraries
- **Vue.js and Django Communities** - For excellent documentation and support
## 📞 Support
- **Issues** - [GitHub Issues](https://github.com/your-repo/thrillwiki-monorepo/issues)
- **Discussions** - [GitHub Discussions](https://github.com/your-repo/thrillwiki-monorepo/discussions)
- **Documentation** - [Project Wiki](https://github.com/your-repo/thrillwiki-monorepo/wiki)
---
**Built with ❤️ for the theme park and roller coaster community**

View File

@@ -2,7 +2,14 @@ from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from django.utils.html import format_html
from django.contrib.auth.models import Group
from .models import User, UserProfile, EmailVerification, TopList, TopListItem
from .models import (
User,
UserProfile,
EmailVerification,
PasswordReset,
TopList,
TopListItem,
)
class UserProfileInline(admin.StackedInline):
@@ -280,3 +287,74 @@ class TopListItemAdmin(admin.ModelAdmin):
("List Information", {"fields": ("top_list", "rank")}),
("Item Details", {"fields": ("content_type", "object_id", "notes")}),
)
@admin.register(PasswordReset)
class PasswordResetAdmin(admin.ModelAdmin):
"""Admin interface for password reset tokens"""
list_display = (
"user",
"created_at",
"expires_at",
"is_expired",
"used",
)
list_filter = (
"used",
"created_at",
"expires_at",
)
search_fields = (
"user__username",
"user__email",
"token",
)
readonly_fields = (
"token",
"created_at",
"expires_at",
)
date_hierarchy = "created_at"
ordering = ("-created_at",)
fieldsets = (
(
"Reset Details",
{
"fields": (
"user",
"token",
"used",
)
},
),
(
"Timing",
{
"fields": (
"created_at",
"expires_at",
)
},
),
)
@admin.display(description="Status", boolean=True)
def is_expired(self, obj):
"""Display expiration status with color coding"""
from django.utils import timezone
if obj.used:
return format_html('<span style="color: blue;">Used</span>')
elif timezone.now() > obj.expires_at:
return format_html('<span style="color: red;">Expired</span>')
return format_html('<span style="color: green;">Valid</span>')
def has_add_permission(self, request):
"""Disable manual creation of password reset tokens"""
return False
def has_change_permission(self, request, obj=None):
"""Allow viewing but restrict editing of password reset tokens"""
return getattr(request.user, "is_superuser", False)

View File

@@ -0,0 +1,51 @@
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp
from django.contrib.sites.models import Site
class Command(BaseCommand):
help = 'Set up social authentication providers for development'
def handle(self, *args, **options):
# Get the current site
site = Site.objects.get_current()
self.stdout.write(f'Setting up social providers for site: {site}')
# Clear existing social apps to avoid duplicates
deleted_count = SocialApp.objects.all().delete()[0]
self.stdout.write(f'Cleared {deleted_count} existing social apps')
# Create Google social app
google_app = SocialApp.objects.create(
provider='google',
name='Google',
client_id='demo-google-client-id.apps.googleusercontent.com',
secret='demo-google-client-secret',
key='',
)
google_app.sites.add(site)
self.stdout.write(
self.style.SUCCESS('✅ Created Google social app')
)
# Create Discord social app
discord_app = SocialApp.objects.create(
provider='discord',
name='Discord',
client_id='demo-discord-client-id',
secret='demo-discord-client-secret',
key='',
)
discord_app.sites.add(site)
self.stdout.write(
self.style.SUCCESS('✅ Created Discord social app')
)
# List all social apps
self.stdout.write('\nConfigured social apps:')
for app in SocialApp.objects.all():
self.stdout.write(f'- {app.name} ({app.provider}): {app.client_id}')
self.stdout.write(
self.style.SUCCESS(f'\nTotal social apps: {SocialApp.objects.count()}')
)

View File

@@ -0,0 +1,64 @@
# Generated by Django 5.2.5 on 2025-08-24 18:23
import pgtrigger.migrations
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("accounts", "0001_initial"),
]
operations = [
migrations.RemoveField(
model_name="toplistevent",
name="pgh_context",
),
migrations.RemoveField(
model_name="toplistevent",
name="pgh_obj",
),
migrations.RemoveField(
model_name="toplistevent",
name="user",
),
migrations.RemoveField(
model_name="toplistitemevent",
name="content_type",
),
migrations.RemoveField(
model_name="toplistitemevent",
name="pgh_context",
),
migrations.RemoveField(
model_name="toplistitemevent",
name="pgh_obj",
),
migrations.RemoveField(
model_name="toplistitemevent",
name="top_list",
),
pgtrigger.migrations.RemoveTrigger(
model_name="toplist",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="toplist",
name="update_update",
),
pgtrigger.migrations.RemoveTrigger(
model_name="toplistitem",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="toplistitem",
name="update_update",
),
migrations.DeleteModel(
name="TopListEvent",
),
migrations.DeleteModel(
name="TopListItemEvent",
),
]

View File

@@ -0,0 +1,439 @@
# Generated by Django 5.2.5 on 2025-08-24 19:11
import django.contrib.auth.validators
import django.db.models.deletion
import django.utils.timezone
import pgtrigger.compiler
import pgtrigger.migrations
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0002_remove_toplistevent_pgh_context_and_more"),
("pghistory", "0007_auto_20250421_0444"),
]
operations = [
migrations.CreateModel(
name="EmailVerificationEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
("token", models.CharField(max_length=64)),
("created_at", models.DateTimeField(auto_now_add=True)),
("last_sent", models.DateTimeField(auto_now_add=True)),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="PasswordResetEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
("token", models.CharField(max_length=64)),
("created_at", models.DateTimeField(auto_now_add=True)),
("expires_at", models.DateTimeField()),
("used", models.BooleanField(default=False)),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="UserEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
("password", models.CharField(max_length=128, verbose_name="password")),
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
(
"username",
models.CharField(
error_messages={
"unique": "A user with that username already exists."
},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
],
verbose_name="username",
),
),
(
"first_name",
models.CharField(
blank=True, max_length=150, verbose_name="first name"
),
),
(
"last_name",
models.CharField(
blank=True, max_length=150, verbose_name="last name"
),
),
(
"email",
models.EmailField(
blank=True, max_length=254, verbose_name="email address"
),
),
(
"is_staff",
models.BooleanField(
default=False,
help_text="Designates whether the user can log into this admin site.",
verbose_name="staff status",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
verbose_name="active",
),
),
(
"date_joined",
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="date joined"
),
),
(
"user_id",
models.CharField(
editable=False,
help_text="Unique identifier for this user that remains constant even if the username changes",
max_length=10,
),
),
(
"role",
models.CharField(
choices=[
("USER", "User"),
("MODERATOR", "Moderator"),
("ADMIN", "Admin"),
("SUPERUSER", "Superuser"),
],
default="USER",
max_length=10,
),
),
("is_banned", models.BooleanField(default=False)),
("ban_reason", models.TextField(blank=True)),
("ban_date", models.DateTimeField(blank=True, null=True)),
(
"pending_email",
models.EmailField(blank=True, max_length=254, null=True),
),
(
"theme_preference",
models.CharField(
choices=[("light", "Light"), ("dark", "Dark")],
default="light",
max_length=5,
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="UserProfileEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
(
"profile_id",
models.CharField(
editable=False,
help_text="Unique identifier for this profile that remains constant",
max_length=10,
),
),
(
"display_name",
models.CharField(
help_text="This is the name that will be displayed on the site",
max_length=50,
),
),
("avatar", models.ImageField(blank=True, upload_to="avatars/")),
("pronouns", models.CharField(blank=True, max_length=50)),
("bio", models.TextField(blank=True, max_length=500)),
("twitter", models.URLField(blank=True)),
("instagram", models.URLField(blank=True)),
("youtube", models.URLField(blank=True)),
("discord", models.CharField(blank=True, max_length=100)),
("coaster_credits", models.IntegerField(default=0)),
("dark_ride_credits", models.IntegerField(default=0)),
("flat_ride_credits", models.IntegerField(default=0)),
("water_ride_credits", models.IntegerField(default=0)),
],
options={
"abstract": False,
},
),
pgtrigger.migrations.AddTrigger(
model_name="emailverification",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_emailverificationevent" ("created_at", "id", "last_sent", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "token", "user_id") VALUES (NEW."created_at", NEW."id", NEW."last_sent", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."token", NEW."user_id"); RETURN NULL;',
hash="c485bf0cd5bea8a05ef2d4ae309b60eff42abd84",
operation="INSERT",
pgid="pgtrigger_insert_insert_53748",
table="accounts_emailverification",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="emailverification",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_emailverificationevent" ("created_at", "id", "last_sent", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "token", "user_id") VALUES (NEW."created_at", NEW."id", NEW."last_sent", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."token", NEW."user_id"); RETURN NULL;',
hash="c20942bdc0713db74310da8da8c3138ca4c3bba9",
operation="UPDATE",
pgid="pgtrigger_update_update_7a2a8",
table="accounts_emailverification",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="passwordreset",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_passwordresetevent" ("created_at", "expires_at", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "token", "used", "user_id") VALUES (NEW."created_at", NEW."expires_at", NEW."id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."token", NEW."used", NEW."user_id"); RETURN NULL;',
hash="496ac059671b25460cdf2ca20d0e43b14d417a26",
operation="INSERT",
pgid="pgtrigger_insert_insert_d2b72",
table="accounts_passwordreset",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="passwordreset",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_passwordresetevent" ("created_at", "expires_at", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "token", "used", "user_id") VALUES (NEW."created_at", NEW."expires_at", NEW."id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."token", NEW."used", NEW."user_id"); RETURN NULL;',
hash="c40acc416f85287b4a6fcc06724626707df90016",
operation="UPDATE",
pgid="pgtrigger_update_update_526d2",
table="accounts_passwordreset",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="user",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_userevent" ("ban_date", "ban_reason", "date_joined", "email", "first_name", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_name", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "role", "theme_preference", "user_id", "username") VALUES (NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."email", NEW."first_name", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_name", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."role", NEW."theme_preference", NEW."user_id", NEW."username"); RETURN NULL;',
hash="b6992f02a4c1135fef9527e3f1ed330e2e626267",
operation="INSERT",
pgid="pgtrigger_insert_insert_3867c",
table="accounts_user",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="user",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_userevent" ("ban_date", "ban_reason", "date_joined", "email", "first_name", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_name", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "role", "theme_preference", "user_id", "username") VALUES (NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."email", NEW."first_name", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_name", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."role", NEW."theme_preference", NEW."user_id", NEW."username"); RETURN NULL;',
hash="6c3271b9f184dc137da7b9e42b0ae9f72d47c9c2",
operation="UPDATE",
pgid="pgtrigger_update_update_0e890",
table="accounts_user",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="userprofile",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_userprofileevent" ("avatar", "bio", "coaster_credits", "dark_ride_credits", "discord", "display_name", "flat_ride_credits", "id", "instagram", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "profile_id", "pronouns", "twitter", "user_id", "water_ride_credits", "youtube") VALUES (NEW."avatar", NEW."bio", NEW."coaster_credits", NEW."dark_ride_credits", NEW."discord", NEW."display_name", NEW."flat_ride_credits", NEW."id", NEW."instagram", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."profile_id", NEW."pronouns", NEW."twitter", NEW."user_id", NEW."water_ride_credits", NEW."youtube"); RETURN NULL;',
hash="af6a89f13ff879d978a1154bbcf4664de0fcf913",
operation="INSERT",
pgid="pgtrigger_insert_insert_c09d7",
table="accounts_userprofile",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="userprofile",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_userprofileevent" ("avatar", "bio", "coaster_credits", "dark_ride_credits", "discord", "display_name", "flat_ride_credits", "id", "instagram", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "profile_id", "pronouns", "twitter", "user_id", "water_ride_credits", "youtube") VALUES (NEW."avatar", NEW."bio", NEW."coaster_credits", NEW."dark_ride_credits", NEW."discord", NEW."display_name", NEW."flat_ride_credits", NEW."id", NEW."instagram", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."profile_id", NEW."pronouns", NEW."twitter", NEW."user_id", NEW."water_ride_credits", NEW."youtube"); RETURN NULL;',
hash="37e99b5cc374ec0a3fc44d2482b411cba63fa84d",
operation="UPDATE",
pgid="pgtrigger_update_update_87ef6",
table="accounts_userprofile",
when="AFTER",
),
),
),
migrations.AddField(
model_name="emailverificationevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
migrations.AddField(
model_name="emailverificationevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="accounts.emailverification",
),
),
migrations.AddField(
model_name="emailverificationevent",
name="user",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="passwordresetevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
migrations.AddField(
model_name="passwordresetevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="accounts.passwordreset",
),
),
migrations.AddField(
model_name="passwordresetevent",
name="user",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="userevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
migrations.AddField(
model_name="userevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="userprofileevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
migrations.AddField(
model_name="userprofileevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="accounts.userprofile",
),
),
migrations.AddField(
model_name="userprofileevent",
name="user",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
]

View File

@@ -5,8 +5,7 @@ from django.utils.translation import gettext_lazy as _
import os
import secrets
from apps.core.history import TrackedModel
# import pghistory
import pghistory
def generate_random_id(model_class, id_field):
@@ -23,6 +22,7 @@ def generate_random_id(model_class, id_field):
return new_id
@pghistory.track()
class User(AbstractUser):
class Roles(models.TextChoices):
USER = "USER", _("User")
@@ -79,6 +79,7 @@ class User(AbstractUser):
super().save(*args, **kwargs)
@pghistory.track()
class UserProfile(models.Model):
# Read-only ID
profile_id = models.CharField(
@@ -137,6 +138,7 @@ class UserProfile(models.Model):
return self.display_name
@pghistory.track()
class EmailVerification(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
token = models.CharField(max_length=64, unique=True)
@@ -151,6 +153,7 @@ class EmailVerification(models.Model):
verbose_name_plural = "Email Verifications"
@pghistory.track()
class PasswordReset(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
token = models.CharField(max_length=64)

View File

@@ -0,0 +1,246 @@
from rest_framework import serializers
from django.contrib.auth import get_user_model
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError
from django.utils.crypto import get_random_string
from django.utils import timezone
from datetime import timedelta
from django.contrib.sites.shortcuts import get_current_site
from .models import User, PasswordReset
from apps.email_service.services import EmailService
from django.template.loader import render_to_string
UserModel = get_user_model()
class UserSerializer(serializers.ModelSerializer):
"""
User serializer for API responses
"""
avatar_url = serializers.SerializerMethodField()
class Meta:
model = User
fields = [
'id', 'username', 'email', 'first_name', 'last_name',
'date_joined', 'is_active', 'avatar_url'
]
read_only_fields = ['id', 'date_joined', 'is_active']
def get_avatar_url(self, obj):
"""Get user avatar URL"""
if hasattr(obj, 'profile') and obj.profile.avatar:
return obj.profile.avatar.url
return None
class LoginSerializer(serializers.Serializer):
"""
Serializer for user login
"""
username = serializers.CharField(
max_length=254,
help_text="Username or email address"
)
password = serializers.CharField(
max_length=128,
style={'input_type': 'password'},
trim_whitespace=False
)
def validate(self, attrs):
username = attrs.get('username')
password = attrs.get('password')
if username and password:
return attrs
raise serializers.ValidationError(
'Must include username/email and password.'
)
class SignupSerializer(serializers.ModelSerializer):
"""
Serializer for user registration
"""
password = serializers.CharField(
write_only=True,
validators=[validate_password],
style={'input_type': 'password'}
)
password_confirm = serializers.CharField(
write_only=True,
style={'input_type': 'password'}
)
class Meta:
model = User
fields = [
'username', 'email', 'first_name', 'last_name',
'password', 'password_confirm'
]
extra_kwargs = {
'password': {'write_only': True},
'email': {'required': True},
}
def validate_email(self, value):
"""Validate email is unique"""
if UserModel.objects.filter(email=value).exists():
raise serializers.ValidationError(
"A user with this email already exists."
)
return value
def validate_username(self, value):
"""Validate username is unique"""
if UserModel.objects.filter(username=value).exists():
raise serializers.ValidationError(
"A user with this username already exists."
)
return value
def validate(self, attrs):
"""Validate passwords match"""
password = attrs.get('password')
password_confirm = attrs.get('password_confirm')
if password != password_confirm:
raise serializers.ValidationError({
'password_confirm': 'Passwords do not match.'
})
return attrs
def create(self, validated_data):
"""Create user with validated data"""
validated_data.pop('password_confirm', None)
password = validated_data.pop('password')
user = UserModel.objects.create(
**validated_data
)
user.set_password(password)
user.save()
return user
class PasswordResetSerializer(serializers.Serializer):
"""
Serializer for password reset request
"""
email = serializers.EmailField()
def validate_email(self, value):
"""Validate email exists"""
try:
user = UserModel.objects.get(email=value)
self.user = user
return value
except UserModel.DoesNotExist:
# Don't reveal if email exists or not for security
return value
def save(self, **kwargs):
"""Send password reset email if user exists"""
if hasattr(self, 'user'):
# Create password reset token
token = get_random_string(64)
PasswordReset.objects.update_or_create(
user=self.user,
defaults={
'token': token,
'expires_at': timezone.now() + timedelta(hours=24),
'used': False
}
)
# Send reset email
request = self.context.get('request')
if request:
site = get_current_site(request)
reset_url = f"{request.scheme}://{site.domain}/reset-password/{token}/"
context = {
'user': self.user,
'reset_url': reset_url,
'site_name': site.name,
}
email_html = render_to_string(
'accounts/email/password_reset.html',
context
)
EmailService.send_email(
to=getattr(self.user, 'email', None),
subject="Reset your password",
text=f"Click the link to reset your password: {reset_url}",
site=site,
html=email_html,
)
class PasswordChangeSerializer(serializers.Serializer):
"""
Serializer for password change
"""
old_password = serializers.CharField(
max_length=128,
style={'input_type': 'password'}
)
new_password = serializers.CharField(
max_length=128,
validators=[validate_password],
style={'input_type': 'password'}
)
new_password_confirm = serializers.CharField(
max_length=128,
style={'input_type': 'password'}
)
def validate_old_password(self, value):
"""Validate old password is correct"""
user = self.context['request'].user
if not user.check_password(value):
raise serializers.ValidationError(
'Old password is incorrect.'
)
return value
def validate(self, attrs):
"""Validate new passwords match"""
new_password = attrs.get('new_password')
new_password_confirm = attrs.get('new_password_confirm')
if new_password != new_password_confirm:
raise serializers.ValidationError({
'new_password_confirm': 'New passwords do not match.'
})
return attrs
def save(self, **kwargs):
"""Change user password"""
user = self.context['request'].user
new_password = self.initial_data.get(
'new_password') if self.initial_data else None
if new_password is None:
raise serializers.ValidationError('New password is required.')
user.set_password(new_password)
user.save()
return user
class SocialProviderSerializer(serializers.Serializer):
"""
Serializer for social authentication providers
"""
id = serializers.CharField()
name = serializers.CharField()
login_url = serializers.URLField()

View File

@@ -0,0 +1,5 @@
"""
Consolidated API app for ThrillWiki.
This app provides a unified, versioned API interface for all ThrillWiki resources.
"""

17
backend/apps/api/apps.py Normal file
View File

@@ -0,0 +1,17 @@
"""Django app configuration for the consolidated API."""
from django.apps import AppConfig
class ApiConfig(AppConfig):
"""Configuration for the consolidated API app."""
default_auto_field = "django.db.models.BigAutoField"
name = "apps.api"
def ready(self):
"""Import schema extensions when app is ready."""
try:
import apps.api.v1.schema # noqa: F401
except ImportError:
pass

View File

@@ -0,0 +1,6 @@
"""
ThrillWiki API v1.
This module provides the version 1 REST API for ThrillWiki, consolidating
all endpoints under a unified, well-documented API structure.
"""

View File

@@ -0,0 +1,334 @@
"""
Schema extensions and customizations for drf-spectacular.
This module provides custom extensions to improve OpenAPI schema generation
for the ThrillWiki API, including better documentation and examples.
"""
from drf_spectacular.openapi import AutoSchema
from drf_spectacular.utils import OpenApiExample
from drf_spectacular.types import OpenApiTypes
# Custom examples for common serializers
PARK_EXAMPLE = {
"id": 1,
"name": "Cedar Point",
"slug": "cedar-point",
"description": "The Roller Coaster Capital of the World",
"status": "OPERATING",
"opening_date": "1870-07-04",
"closing_date": None,
"location": {
"latitude": 41.4793,
"longitude": -82.6833,
"city": "Sandusky",
"state": "Ohio",
"country": "United States",
"formatted_address": "Sandusky, OH, United States",
},
"operator": {
"id": 1,
"name": "Cedar Fair",
"slug": "cedar-fair",
"roles": ["OPERATOR", "PROPERTY_OWNER"],
},
"property_owner": {
"id": 1,
"name": "Cedar Fair",
"slug": "cedar-fair",
"roles": ["OPERATOR", "PROPERTY_OWNER"],
},
"area_count": 15,
"ride_count": 70,
"operating_rides_count": 68,
"roller_coaster_count": 17,
}
RIDE_EXAMPLE = {
"id": 1,
"name": "Steel Vengeance",
"slug": "steel-vengeance",
"description": "A hybrid wooden/steel roller coaster",
"category": "ROLLER_COASTER",
"status": "OPERATING",
"opening_date": "2018-05-05",
"closing_date": None,
"park": {"id": 1, "name": "Cedar Point", "slug": "cedar-point"},
"manufacturer": {
"id": 1,
"name": "Rocky Mountain Construction",
"slug": "rmc",
"roles": ["MANUFACTURER"],
},
"designer": {
"id": 1,
"name": "Rocky Mountain Construction",
"slug": "rmc",
"roles": ["DESIGNER"],
},
"height_feet": 205,
"length_feet": 5740,
"speed_mph": 74,
"inversions": 4,
"duration_seconds": 150,
"capacity_per_hour": 1200,
"minimum_height_inches": 48,
"maximum_height_inches": None,
}
COMPANY_EXAMPLE = {
"id": 1,
"name": "Cedar Fair",
"slug": "cedar-fair",
"roles": ["OPERATOR", "PROPERTY_OWNER"],
}
LOCATION_EXAMPLE = {
"latitude": 41.4793,
"longitude": -82.6833,
"city": "Sandusky",
"state": "Ohio",
"country": "United States",
"formatted_address": "Sandusky, OH, United States",
}
HISTORY_EVENT_EXAMPLE = {
"id": "12345678-1234-5678-9012-123456789012",
"pgh_created_at": "2024-01-15T14:30:00Z",
"pgh_label": "updated",
"pgh_model": "parks.park",
"pgh_obj_id": 1,
"pgh_context": {
"user_id": 42,
"request_id": "req_abc123",
"ip_address": "192.168.1.100",
},
"changed_fields": ["name", "description"],
"field_changes": {
"name": {"old_value": "Cedar Point Amusement Park", "new_value": "Cedar Point"},
"description": {
"old_value": "America's Roller Coast",
"new_value": "The Roller Coaster Capital of the World",
},
},
}
PARK_HISTORY_EXAMPLE = {
"park": PARK_EXAMPLE,
"current_state": PARK_EXAMPLE,
"summary": {
"total_events": 25,
"first_recorded": "2023-01-01T00:00:00Z",
"last_modified": "2024-01-15T14:30:00Z",
"significant_changes": [
{
"date": "2024-01-15T14:30:00Z",
"event_type": "updated",
"description": "Name and description updated",
},
{
"date": "2023-06-01T10:00:00Z",
"event_type": "updated",
"description": "Operating status changed",
},
],
},
"events": [HISTORY_EVENT_EXAMPLE],
}
UNIFIED_HISTORY_TIMELINE_EXAMPLE = {
"summary": {
"total_events": 1250,
"events_returned": 100,
"event_type_breakdown": {"created": 45, "updated": 180, "deleted": 5},
"model_type_breakdown": {
"parks.park": 75,
"rides.ride": 120,
"companies.operator": 15,
"companies.manufacturer": 25,
"accounts.user": 30,
},
"time_range": {
"earliest": "2023-01-01T00:00:00Z",
"latest": "2024-01-15T14:30:00Z",
},
},
"events": [
{
"id": "event_001",
"pgh_created_at": "2024-01-15T14:30:00Z",
"pgh_label": "updated",
"pgh_model": "parks.park",
"pgh_obj_id": 1,
"entity_name": "Cedar Point",
"entity_slug": "cedar-point",
"change_significance": "minor",
"change_summary": "Park description updated",
},
{
"id": "event_002",
"pgh_created_at": "2024-01-15T12:00:00Z",
"pgh_label": "created",
"pgh_model": "rides.ride",
"pgh_obj_id": 100,
"entity_name": "New Roller Coaster",
"entity_slug": "new-roller-coaster",
"change_significance": "major",
"change_summary": "New ride added to park",
},
],
}
# OpenAPI schema customizations
def custom_preprocessing_hook(endpoints):
"""
Custom preprocessing hook to modify endpoints before schema generation.
This can be used to filter out certain endpoints, modify their metadata,
or add custom documentation.
"""
# Filter out any endpoints we don't want in the public API
filtered = []
for path, path_regex, method, callback in endpoints:
# Skip internal or debug endpoints
if "/debug/" not in path and "/internal/" not in path:
filtered.append((path, path_regex, method, callback))
return filtered
def custom_postprocessing_hook(result, generator, request, public):
"""
Custom postprocessing hook to modify the generated schema.
This can be used to add custom metadata, modify response schemas,
or enhance the overall API documentation.
"""
# Add custom info to the schema
if "info" in result:
result["info"]["contact"] = {
"name": "ThrillWiki API Support",
"email": "api@thrillwiki.com",
"url": "https://thrillwiki.com/support",
}
result["info"]["license"] = {
"name": "MIT",
"url": "https://opensource.org/licenses/MIT",
}
# Add custom tags with descriptions
if "tags" not in result:
result["tags"] = []
result["tags"].extend(
[
{
"name": "Parks",
"description": "Operations related to theme parks, including CRUD operations and statistics",
},
{
"name": "Rides",
"description": "Operations related to rides and attractions within theme parks",
},
{
"name": "History",
"description": "Historical change tracking for all entities, providing complete audit trails and version history",
"externalDocs": {
"description": "Learn more about pghistory",
"url": "https://django-pghistory.readthedocs.io/",
},
},
{
"name": "Statistics",
"description": "Statistical endpoints providing aggregated data and insights",
},
{
"name": "Reviews",
"description": "User reviews and ratings for parks and rides",
},
{
"name": "Authentication",
"description": "User authentication and account management endpoints",
},
{
"name": "Health",
"description": "System health checks and monitoring endpoints",
},
{
"name": "Recent Changes",
"description": "Endpoints for accessing recently changed entities by type and change category",
},
]
)
# Add custom servers if not present
if "servers" not in result:
result["servers"] = [
{
"url": "https://api.thrillwiki.com/v1",
"description": "Production server",
},
{
"url": "https://staging-api.thrillwiki.com/v1",
"description": "Staging server",
},
{
"url": "http://localhost:8000/api/v1",
"description": "Development server",
},
]
return result
# Custom AutoSchema class for enhanced documentation
class ThrillWikiAutoSchema(AutoSchema):
"""
Custom AutoSchema class that provides enhanced documentation
for ThrillWiki API endpoints.
"""
def get_operation_id(self):
"""Generate meaningful operation IDs."""
if hasattr(self.view, "basename"):
basename = self.view.basename
else:
basename = getattr(self.view, "__class__", self.view).__name__.lower()
if basename.endswith("viewset"):
basename = basename[:-7] # Remove 'viewset' suffix
action = self.method_mapping.get(self.method.lower(), self.method.lower())
return f"{basename}_{action}"
def get_tags(self):
"""Generate tags based on the viewset."""
if hasattr(self.view, "basename"):
return [self.view.basename.title()]
return super().get_tags()
def get_summary(self):
"""Generate summary from docstring or method name."""
summary = super().get_summary()
if summary:
return summary
# Generate from method and model
action = self.method_mapping.get(self.method.lower(), self.method.lower())
model_name = getattr(self.view, "basename", "resource")
action_map = {
"list": f"List {model_name}",
"create": f"Create {model_name}",
"retrieve": f"Get {model_name} details",
"update": f"Update {model_name}",
"partial_update": f"Partially update {model_name}",
"destroy": f"Delete {model_name}",
}
return action_map.get(action, f"{action.title()} {model_name}")

File diff suppressed because it is too large Load Diff

142
backend/apps/api/v1/urls.py Normal file
View File

@@ -0,0 +1,142 @@
"""
URL configuration for ThrillWiki API v1.
This module provides unified API routing following RESTful conventions
and DRF Router patterns for automatic URL generation.
"""
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from drf_spectacular.views import (
SpectacularAPIView,
SpectacularSwaggerView,
SpectacularRedocView,
)
from .viewsets import (
ParkViewSet,
RideViewSet,
ParkReadOnlyViewSet,
RideReadOnlyViewSet,
LoginAPIView,
SignupAPIView,
LogoutAPIView,
CurrentUserAPIView,
PasswordResetAPIView,
PasswordChangeAPIView,
SocialProvidersAPIView,
AuthStatusAPIView,
HealthCheckAPIView,
PerformanceMetricsAPIView,
SimpleHealthAPIView,
# History viewsets
ParkHistoryViewSet,
RideHistoryViewSet,
UnifiedHistoryViewSet,
# New comprehensive viewsets
ParkAreaViewSet,
ParkLocationViewSet,
CompanyViewSet,
RideModelViewSet,
RollerCoasterStatsViewSet,
RideLocationViewSet,
RideReviewViewSet,
UserProfileViewSet,
TopListViewSet,
TopListItemViewSet,
)
# Create the main API router
router = DefaultRouter()
# Register ViewSets with descriptive prefixes
# Core models
router.register(r"parks", ParkViewSet, basename="park")
router.register(r"rides", RideViewSet, basename="ride")
# Park-related models
router.register(r"park-areas", ParkAreaViewSet, basename="park-area")
router.register(r"park-locations", ParkLocationViewSet, basename="park-location")
# Company models
router.register(r"companies", CompanyViewSet, basename="company")
# Ride-related models
router.register(r"ride-models", RideModelViewSet, basename="ride-model")
router.register(
r"roller-coaster-stats", RollerCoasterStatsViewSet, basename="roller-coaster-stats"
)
router.register(r"ride-locations", RideLocationViewSet, basename="ride-location")
router.register(r"ride-reviews", RideReviewViewSet, basename="ride-review")
# User-related models
router.register(r"user-profiles", UserProfileViewSet, basename="user-profile")
router.register(r"top-lists", TopListViewSet, basename="top-list")
router.register(r"top-list-items", TopListItemViewSet, basename="top-list-item")
# Register read-only endpoints for reference data
router.register(r"ref/parks", ParkReadOnlyViewSet, basename="park-ref")
router.register(r"ref/rides", RideReadOnlyViewSet, basename="ride-ref")
app_name = "api_v1"
urlpatterns = [
# API Documentation endpoints
path("schema/", SpectacularAPIView.as_view(), name="schema"),
path(
"docs/",
SpectacularSwaggerView.as_view(url_name="api_v1:schema"),
name="swagger-ui",
),
path(
"redoc/", SpectacularRedocView.as_view(url_name="api_v1:schema"), name="redoc"
),
# Authentication endpoints
path("auth/login/", LoginAPIView.as_view(), name="login"),
path("auth/signup/", SignupAPIView.as_view(), name="signup"),
path("auth/logout/", LogoutAPIView.as_view(), name="logout"),
path("auth/user/", CurrentUserAPIView.as_view(), name="current-user"),
path("auth/password/reset/", PasswordResetAPIView.as_view(), name="password-reset"),
path(
"auth/password/change/", PasswordChangeAPIView.as_view(), name="password-change"
),
path("auth/providers/", SocialProvidersAPIView.as_view(), name="social-providers"),
path("auth/status/", AuthStatusAPIView.as_view(), name="auth-status"),
# Health check endpoints
path("health/", HealthCheckAPIView.as_view(), name="health-check"),
path("health/simple/", SimpleHealthAPIView.as_view(), name="simple-health"),
path(
"health/performance/",
PerformanceMetricsAPIView.as_view(),
name="performance-metrics",
),
# History endpoints
path(
"history/timeline/",
UnifiedHistoryViewSet.as_view({"get": "list"}),
name="unified-history-timeline",
),
path(
"parks/<str:park_slug>/history/",
ParkHistoryViewSet.as_view({"get": "list"}),
name="park-history-list",
),
path(
"parks/<str:park_slug>/history/detail/",
ParkHistoryViewSet.as_view({"get": "retrieve"}),
name="park-history-detail",
),
path(
"parks/<str:park_slug>/rides/<str:ride_slug>/history/",
RideHistoryViewSet.as_view({"get": "list"}),
name="ride-history-list",
),
path(
"parks/<str:park_slug>/rides/<str:ride_slug>/history/detail/",
RideHistoryViewSet.as_view({"get": "retrieve"}),
name="ride-history-detail",
),
# Include all router-generated URLs
path("", include(router.urls)),
]

File diff suppressed because it is too large Load Diff

View File

@@ -3,8 +3,11 @@ from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from django.db.models import Count
from datetime import timedelta
import pghistory
@pghistory.track()
class PageView(models.Model):
content_type = models.ForeignKey(
ContentType, on_delete=models.CASCADE, related_name="page_views"
@@ -35,7 +38,7 @@ class PageView(models.Model):
QuerySet: The trending items ordered by view count
"""
content_type = ContentType.objects.get_for_model(model_class)
cutoff = timezone.now() - timezone.timedelta(hours=hours)
cutoff = timezone.now() - timedelta(hours=hours)
# Query through the ContentType relationship
item_ids = (

View File

@@ -0,0 +1,171 @@
# Generated by Django 5.2.5 on 2025-08-24 19:25
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
("core", "0002_historicalslug_pageview"),
("pghistory", "0007_auto_20250421_0444"),
]
operations = [
migrations.CreateModel(
name="PageViewEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
("object_id", models.PositiveIntegerField()),
("timestamp", models.DateTimeField(auto_now_add=True)),
("ip_address", models.GenericIPAddressField()),
("user_agent", models.CharField(blank=True, max_length=512)),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="SlugHistoryEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
("object_id", models.CharField(max_length=50)),
("old_slug", models.SlugField(db_index=False, max_length=200)),
("created_at", models.DateTimeField(auto_now_add=True)),
],
options={
"abstract": False,
},
),
pgtrigger.migrations.AddTrigger(
model_name="pageview",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "core_pageviewevent" ("content_type_id", "id", "ip_address", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "timestamp", "user_agent") VALUES (NEW."content_type_id", NEW."id", NEW."ip_address", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."timestamp", NEW."user_agent"); RETURN NULL;',
hash="1682d124ea3ba215e630c7cfcde929f7444cf247",
operation="INSERT",
pgid="pgtrigger_insert_insert_ee1e1",
table="core_pageview",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="pageview",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "core_pageviewevent" ("content_type_id", "id", "ip_address", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "timestamp", "user_agent") VALUES (NEW."content_type_id", NEW."id", NEW."ip_address", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."timestamp", NEW."user_agent"); RETURN NULL;',
hash="4221b2dd6636cae454f8d69c0c1841c40c47e6a6",
operation="UPDATE",
pgid="pgtrigger_update_update_3c505",
table="core_pageview",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="slughistory",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "core_slughistoryevent" ("content_type_id", "created_at", "id", "object_id", "old_slug", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id") VALUES (NEW."content_type_id", NEW."created_at", NEW."id", NEW."object_id", NEW."old_slug", _pgh_attach_context(), NOW(), \'insert\', NEW."id"); RETURN NULL;',
hash="2a2a05025693c165b88e5eba7fcc23214749a78b",
operation="INSERT",
pgid="pgtrigger_insert_insert_3002a",
table="core_slughistory",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="slughistory",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "core_slughistoryevent" ("content_type_id", "created_at", "id", "object_id", "old_slug", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id") VALUES (NEW."content_type_id", NEW."created_at", NEW."id", NEW."object_id", NEW."old_slug", _pgh_attach_context(), NOW(), \'update\', NEW."id"); RETURN NULL;',
hash="3ad197ccb6178668e762720341e45d3fd3216776",
operation="UPDATE",
pgid="pgtrigger_update_update_52030",
table="core_slughistory",
when="AFTER",
),
),
),
migrations.AddField(
model_name="pageviewevent",
name="content_type",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="contenttypes.contenttype",
),
),
migrations.AddField(
model_name="pageviewevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
migrations.AddField(
model_name="pageviewevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="core.pageview",
),
),
migrations.AddField(
model_name="slughistoryevent",
name="content_type",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="contenttypes.contenttype",
),
),
migrations.AddField(
model_name="slughistoryevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
migrations.AddField(
model_name="slughistoryevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="core.slughistory",
),
),
]

View File

@@ -3,8 +3,10 @@ from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.utils.text import slugify
from apps.core.history import TrackedModel
import pghistory
@pghistory.track()
class SlugHistory(models.Model):
"""
Model for tracking slug changes across all models that use slugs.
@@ -40,7 +42,7 @@ class SluggedModel(TrackedModel):
name = models.CharField(max_length=200)
slug = models.SlugField(max_length=200, unique=True)
class Meta:
class Meta(TrackedModel.Meta):
abstract = True
def save(self, *args, **kwargs):
@@ -83,16 +85,20 @@ class SluggedModel(TrackedModel):
# Try to get by current slug first
return cls.objects.get(slug=slug), False
except cls.DoesNotExist:
# Check pghistory first
history_model = cls.get_history_model()
history_entry = (
history_model.objects.filter(slug=slug)
.order_by("-pgh_created_at")
.first()
)
# Check pghistory first if available
try:
import pghistory.models
history_entries = pghistory.models.Events.objects.filter(
pgh_model=f"{cls._meta.app_label}.{cls._meta.model_name}", slug=slug
).order_by("-pgh_created_at")
if history_entries:
history_entry = history_entries.first()
if history_entry:
return cls.objects.get(id=history_entry.pgh_obj_id), True
except (ImportError, AttributeError):
pass
# Try to find in manual slug history as fallback
history = (
@@ -106,7 +112,7 @@ class SluggedModel(TrackedModel):
if history:
return (
cls.objects.get(**{cls.get_id_field_name(): history.object_id}),
cls.objects.get(**{cls().get_id_field_name(): history.object_id}),
True,
)

View File

@@ -1,273 +0,0 @@
"""
Enhanced health check views for API monitoring.
"""
import time
from django.http import JsonResponse
from django.utils import timezone
from django.views import View
from django.conf import settings
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
from health_check.views import MainView
from apps.core.services.enhanced_cache_service import CacheMonitor
from apps.core.utils.query_optimization import IndexAnalyzer
class HealthCheckAPIView(APIView):
"""
Enhanced API endpoint for health checks with detailed JSON response
"""
permission_classes = [AllowAny] # Public endpoint
def get(self, request):
"""Return comprehensive health check information"""
start_time = time.time()
# Get basic health check results
main_view = MainView()
main_view.request = request
plugins = main_view.plugins
errors = main_view.errors
# Collect additional performance metrics
cache_monitor = CacheMonitor()
cache_stats = cache_monitor.get_cache_stats()
# Build comprehensive health data
health_data = {
"status": "healthy" if not errors else "unhealthy",
"timestamp": timezone.now().isoformat(),
"version": getattr(settings, "VERSION", "1.0.0"),
"environment": getattr(settings, "ENVIRONMENT", "development"),
"response_time_ms": 0, # Will be calculated at the end
"checks": {},
"metrics": {
"cache": cache_stats,
"database": self._get_database_metrics(),
"system": self._get_system_metrics(),
},
}
# Process individual health checks
for plugin in plugins:
plugin_name = plugin.identifier()
plugin_errors = errors.get(plugin.__class__.__name__, [])
health_data["checks"][plugin_name] = {
"status": "healthy" if not plugin_errors else "unhealthy",
"critical": getattr(plugin, "critical_service", False),
"errors": [str(error) for error in plugin_errors],
"response_time_ms": getattr(plugin, "_response_time", None),
}
# Calculate total response time
health_data["response_time_ms"] = round((time.time() - start_time) * 1000, 2)
# Determine HTTP status code
status_code = 200
if errors:
# Check if any critical services are failing
critical_errors = any(
getattr(plugin, "critical_service", False)
for plugin in plugins
if errors.get(plugin.__class__.__name__)
)
status_code = 503 if critical_errors else 200
return Response(health_data, status=status_code)
def _get_database_metrics(self):
"""Get database performance metrics"""
try:
from django.db import connection
# Get basic connection info
metrics = {
"vendor": connection.vendor,
"connection_status": "connected",
}
# Test query performance
start_time = time.time()
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
cursor.fetchone()
query_time = (time.time() - start_time) * 1000
metrics["test_query_time_ms"] = round(query_time, 2)
# PostgreSQL specific metrics
if connection.vendor == "postgresql":
try:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT
numbackends as active_connections,
xact_commit as transactions_committed,
xact_rollback as transactions_rolled_back,
blks_read as blocks_read,
blks_hit as blocks_hit
FROM pg_stat_database
WHERE datname = current_database()
"""
)
row = cursor.fetchone()
if row:
metrics.update(
{
"active_connections": row[0],
"transactions_committed": row[1],
"transactions_rolled_back": row[2],
"cache_hit_ratio": (
round(
(row[4] / (row[3] + row[4])) * 100,
2,
)
if (row[3] + row[4]) > 0
else 0
),
}
)
except Exception:
pass # Skip advanced metrics if not available
return metrics
except Exception as e:
return {"connection_status": "error", "error": str(e)}
def _get_system_metrics(self):
"""Get system performance metrics"""
metrics = {
"debug_mode": settings.DEBUG,
"allowed_hosts": (settings.ALLOWED_HOSTS if settings.DEBUG else ["hidden"]),
}
try:
import psutil
# Memory metrics
memory = psutil.virtual_memory()
metrics["memory"] = {
"total_mb": round(memory.total / 1024 / 1024, 2),
"available_mb": round(memory.available / 1024 / 1024, 2),
"percent_used": memory.percent,
}
# CPU metrics
metrics["cpu"] = {
"percent_used": psutil.cpu_percent(interval=0.1),
"core_count": psutil.cpu_count(),
}
# Disk metrics
disk = psutil.disk_usage("/")
metrics["disk"] = {
"total_gb": round(disk.total / 1024 / 1024 / 1024, 2),
"free_gb": round(disk.free / 1024 / 1024 / 1024, 2),
"percent_used": round((disk.used / disk.total) * 100, 2),
}
except ImportError:
metrics["system_monitoring"] = "psutil not available"
except Exception as e:
metrics["system_error"] = str(e)
return metrics
class PerformanceMetricsView(APIView):
"""
API view for performance metrics and database analysis
"""
permission_classes = [AllowAny] if settings.DEBUG else []
def get(self, request):
"""Return performance metrics and analysis"""
if not settings.DEBUG:
return Response({"error": "Only available in debug mode"}, status=403)
metrics = {
"timestamp": timezone.now().isoformat(),
"database_analysis": self._get_database_analysis(),
"cache_performance": self._get_cache_performance(),
"recent_slow_queries": self._get_slow_queries(),
}
return Response(metrics)
def _get_database_analysis(self):
"""Analyze database performance"""
try:
from django.db import connection
analysis = {
"total_queries": len(connection.queries),
"query_analysis": IndexAnalyzer.analyze_slow_queries(0.05),
}
if connection.queries:
query_times = [float(q.get("time", 0)) for q in connection.queries]
analysis.update(
{
"total_query_time": sum(query_times),
"average_query_time": sum(query_times) / len(query_times),
"slowest_query_time": max(query_times),
"fastest_query_time": min(query_times),
}
)
return analysis
except Exception as e:
return {"error": str(e)}
def _get_cache_performance(self):
"""Get cache performance metrics"""
try:
cache_monitor = CacheMonitor()
return cache_monitor.get_cache_stats()
except Exception as e:
return {"error": str(e)}
def _get_slow_queries(self):
"""Get recent slow queries"""
try:
return IndexAnalyzer.analyze_slow_queries(0.1) # 100ms threshold
except Exception as e:
return {"error": str(e)}
class SimpleHealthView(View):
"""
Simple health check endpoint for load balancers
"""
def get(self, request):
"""Return simple OK status"""
try:
# Basic database connectivity test
from django.db import connection
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
cursor.fetchone()
return JsonResponse(
{"status": "ok", "timestamp": timezone.now().isoformat()}
)
except Exception as e:
return JsonResponse(
{
"status": "error",
"error": str(e),
"timestamp": timezone.now().isoformat(),
},
status=503,
)

View File

@@ -0,0 +1,52 @@
# Generated by Django 5.2.5 on 2025-08-24 18:23
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("email_service", "0001_initial"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="emailconfiguration",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="emailconfiguration",
name="update_update",
),
pgtrigger.migrations.AddTrigger(
model_name="emailconfiguration",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "email_service_emailconfigurationevent" ("api_key", "created_at", "from_email", "from_name", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "reply_to", "site_id", "updated_at") VALUES (NEW."api_key", NEW."created_at", NEW."from_email", NEW."from_name", NEW."id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."reply_to", NEW."site_id", NEW."updated_at"); RETURN NULL;',
hash="f19f3c7f7d904d5f850a2ff1e0bf1312e855c8c0",
operation="INSERT",
pgid="pgtrigger_insert_insert_08c59",
table="email_service_emailconfiguration",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="emailconfiguration",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "email_service_emailconfigurationevent" ("api_key", "created_at", "from_email", "from_name", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "reply_to", "site_id", "updated_at") VALUES (NEW."api_key", NEW."created_at", NEW."from_email", NEW."from_name", NEW."id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."reply_to", NEW."site_id", NEW."updated_at"); RETURN NULL;',
hash="e445521baf2cfb51379b2a6be550b4a638d60202",
operation="UPDATE",
pgid="pgtrigger_update_update_992a4",
table="email_service_emailconfiguration",
when="AFTER",
),
),
),
]

View File

@@ -0,0 +1,52 @@
# Generated by Django 5.2.5 on 2025-08-24 18:23
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("location", "0002_add_business_constraints"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="location",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="location",
name="update_update",
),
pgtrigger.migrations.AddTrigger(
model_name="location",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "location_locationevent" ("city", "content_type_id", "country", "created_at", "id", "latitude", "location_type", "longitude", "name", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "point", "postal_code", "state", "street_address", "updated_at") VALUES (NEW."city", NEW."content_type_id", NEW."country", NEW."created_at", NEW."id", NEW."latitude", NEW."location_type", NEW."longitude", NEW."name", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."point", NEW."postal_code", NEW."state", NEW."street_address", NEW."updated_at"); RETURN NULL;',
hash="8a8f00869cfcaa1a23ab29b3d855e83602172c67",
operation="INSERT",
pgid="pgtrigger_insert_insert_98cd4",
table="location_location",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="location",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "location_locationevent" ("city", "content_type_id", "country", "created_at", "id", "latitude", "location_type", "longitude", "name", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "point", "postal_code", "state", "street_address", "updated_at") VALUES (NEW."city", NEW."content_type_id", NEW."country", NEW."created_at", NEW."id", NEW."latitude", NEW."location_type", NEW."longitude", NEW."name", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."point", NEW."postal_code", NEW."state", NEW."street_address", NEW."updated_at"); RETURN NULL;',
hash="f3378cb26a5d88aa82c8fae016d46037b530de90",
operation="UPDATE",
pgid="pgtrigger_update_update_471d2",
table="location_location",
when="AFTER",
),
),
),
]

View File

@@ -0,0 +1,89 @@
# Generated by Django 5.2.5 on 2025-08-24 18:23
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("moderation", "0001_initial"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="editsubmission",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="editsubmission",
name="update_update",
),
pgtrigger.migrations.RemoveTrigger(
model_name="photosubmission",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="photosubmission",
name="update_update",
),
pgtrigger.migrations.AddTrigger(
model_name="editsubmission",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "moderation_editsubmissionevent" ("changes", "content_type_id", "created_at", "handled_at", "handled_by_id", "id", "moderator_changes", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "reason", "source", "status", "submission_type", "updated_at", "user_id") VALUES (NEW."changes", NEW."content_type_id", NEW."created_at", NEW."handled_at", NEW."handled_by_id", NEW."id", NEW."moderator_changes", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."reason", NEW."source", NEW."status", NEW."submission_type", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="0e394e419ba234dd23cb0f4f6567611ad71f2a38",
operation="INSERT",
pgid="pgtrigger_insert_insert_2c796",
table="moderation_editsubmission",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="editsubmission",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "moderation_editsubmissionevent" ("changes", "content_type_id", "created_at", "handled_at", "handled_by_id", "id", "moderator_changes", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "reason", "source", "status", "submission_type", "updated_at", "user_id") VALUES (NEW."changes", NEW."content_type_id", NEW."created_at", NEW."handled_at", NEW."handled_by_id", NEW."id", NEW."moderator_changes", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."reason", NEW."source", NEW."status", NEW."submission_type", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="315b76df75a52d610d3d0857fd5821101e551410",
operation="UPDATE",
pgid="pgtrigger_update_update_ab38f",
table="moderation_editsubmission",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="photosubmission",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "moderation_photosubmissionevent" ("caption", "content_type_id", "created_at", "date_taken", "handled_at", "handled_by_id", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo", "status", "updated_at", "user_id") VALUES (NEW."caption", NEW."content_type_id", NEW."created_at", NEW."date_taken", NEW."handled_at", NEW."handled_by_id", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."photo", NEW."status", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="e967ea629575f6b26892db225b40add9a1558cfb",
operation="INSERT",
pgid="pgtrigger_insert_insert_62865",
table="moderation_photosubmission",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="photosubmission",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "moderation_photosubmissionevent" ("caption", "content_type_id", "created_at", "date_taken", "handled_at", "handled_by_id", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo", "status", "updated_at", "user_id") VALUES (NEW."caption", NEW."content_type_id", NEW."created_at", NEW."date_taken", NEW."handled_at", NEW."handled_by_id", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."photo", NEW."status", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="b7a97f4e8f90569a90fc4c35cc85e601ff25f0d9",
operation="UPDATE",
pgid="pgtrigger_update_update_9c311",
table="moderation_photosubmission",
when="AFTER",
),
),
),
]

View File

@@ -1,6 +1,15 @@
from django.contrib import admin
from django.contrib.gis.admin import GISModelAdmin
from .models import Park, ParkArea, ParkLocation, Company, CompanyHeadquarters
from django.utils.html import format_html
import pghistory.models
from .models import (
Park,
ParkArea,
ParkLocation,
Company,
CompanyHeadquarters,
ParkReview,
)
class ParkLocationInline(admin.StackedInline):
@@ -79,16 +88,14 @@ class ParkLocationAdmin(GISModelAdmin):
),
)
@admin.display(description="Latitude")
def latitude(self, obj):
return obj.latitude
latitude.short_description = "Latitude"
@admin.display(description="Longitude")
def longitude(self, obj):
return obj.longitude
longitude.short_description = "Longitude"
class ParkAdmin(admin.ModelAdmin):
list_display = (
@@ -112,12 +119,11 @@ class ParkAdmin(admin.ModelAdmin):
prepopulated_fields = {"slug": ("name",)}
inlines = [ParkLocationInline]
@admin.display(description="Location")
def formatted_location(self, obj):
"""Display formatted location string"""
return obj.formatted_location
formatted_location.short_description = "Location"
class ParkAreaAdmin(admin.ModelAdmin):
list_display = ("name", "park", "created_at", "updated_at")
@@ -200,19 +206,193 @@ class CompanyAdmin(admin.ModelAdmin):
prepopulated_fields = {"slug": ("name",)}
inlines = [CompanyHeadquartersInline]
@admin.display(description="Roles")
def roles_display(self, obj):
"""Display roles as a formatted string"""
return ", ".join(obj.roles) if obj.roles else "No roles"
roles_display.short_description = "Roles"
@admin.display(description="Headquarters")
def headquarters_location(self, obj):
"""Display headquarters location if available"""
if hasattr(obj, "headquarters"):
return obj.headquarters.location_display
return "No headquarters"
headquarters_location.short_description = "Headquarters"
@admin.register(ParkReview)
class ParkReviewAdmin(admin.ModelAdmin):
"""Admin interface for park reviews"""
list_display = (
"park",
"user",
"rating",
"title",
"visit_date",
"is_published",
"created_at",
"moderation_status",
)
list_filter = (
"rating",
"is_published",
"visit_date",
"created_at",
"park",
"moderated_by",
)
search_fields = (
"title",
"content",
"user__username",
"park__name",
)
readonly_fields = ("created_at", "updated_at")
date_hierarchy = "created_at"
ordering = ("-created_at",)
fieldsets = (
(
"Review Details",
{
"fields": (
"user",
"park",
"rating",
"title",
"content",
"visit_date",
)
},
),
(
"Publication Status",
{
"fields": ("is_published",),
},
),
(
"Moderation",
{
"fields": (
"moderated_by",
"moderated_at",
"moderation_notes",
),
"classes": ("collapse",),
},
),
(
"Metadata",
{
"fields": ("created_at", "updated_at"),
"classes": ("collapse",),
},
),
)
@admin.display(description="Moderation Status", boolean=True)
def moderation_status(self, obj):
"""Display moderation status with color coding"""
if obj.moderated_by:
return format_html(
'<span style="color: {};">{}</span>',
"green" if obj.is_published else "red",
"Approved" if obj.is_published else "Rejected",
)
return format_html('<span style="color: orange;">Pending</span>')
def save_model(self, request, obj, form, change):
"""Auto-set moderation info when status changes"""
if change and "is_published" in form.changed_data:
from django.utils import timezone
obj.moderated_by = request.user
obj.moderated_at = timezone.now()
super().save_model(request, obj, form, change)
@admin.register(pghistory.models.Events)
class PgHistoryEventsAdmin(admin.ModelAdmin):
"""Admin interface for pghistory Events"""
list_display = (
'pgh_id',
'pgh_created_at',
'pgh_label',
'pgh_model',
'pgh_obj_id',
'pgh_context_display',
)
list_filter = (
'pgh_label',
'pgh_model',
'pgh_created_at',
)
search_fields = (
'pgh_obj_id',
'pgh_context',
)
readonly_fields = (
'pgh_id',
'pgh_created_at',
'pgh_label',
'pgh_model',
'pgh_obj_id',
'pgh_context',
'pgh_data',
)
date_hierarchy = 'pgh_created_at'
ordering = ('-pgh_created_at',)
fieldsets = (
(
'Event Information',
{
'fields': (
'pgh_id',
'pgh_created_at',
'pgh_label',
'pgh_model',
'pgh_obj_id',
)
},
),
(
'Context & Data',
{
'fields': (
'pgh_context',
'pgh_data',
),
'classes': ('collapse',),
},
),
)
@admin.display(description="Context")
def pgh_context_display(self, obj):
"""Display context information in a readable format"""
if obj.pgh_context:
if isinstance(obj.pgh_context, dict):
context_items = []
for key, value in obj.pgh_context.items():
context_items.append(f"{key}: {value}")
return ", ".join(context_items)
return str(obj.pgh_context)
return "No context"
def has_add_permission(self, request):
"""Disable manual creation of history events"""
return False
def has_change_permission(self, request, obj=None):
"""Make history events read-only"""
return False
def has_delete_permission(self, request, obj=None):
"""Prevent deletion of history events"""
return getattr(request.user, 'is_superuser', False)
# Register the models with their admin classes

View File

@@ -1 +0,0 @@
# Parks API module

View File

@@ -1,304 +0,0 @@
"""
Serializers for Parks API following Django styleguide patterns.
Separates Input and Output serializers for clear boundaries.
"""
from rest_framework import serializers
from ..models import Park
class ParkLocationOutputSerializer(serializers.Serializer):
"""Output serializer for park location data."""
latitude = serializers.SerializerMethodField()
longitude = serializers.SerializerMethodField()
city = serializers.SerializerMethodField()
state = serializers.SerializerMethodField()
country = serializers.SerializerMethodField()
formatted_address = serializers.SerializerMethodField()
def get_latitude(self, obj):
if hasattr(obj, "location") and obj.location:
return obj.location.latitude
return None
def get_longitude(self, obj):
if hasattr(obj, "location") and obj.location:
return obj.location.longitude
return None
def get_city(self, obj):
if hasattr(obj, "location") and obj.location:
return obj.location.city
return None
def get_state(self, obj):
if hasattr(obj, "location") and obj.location:
return obj.location.state
return None
def get_country(self, obj):
if hasattr(obj, "location") and obj.location:
return obj.location.country
return None
def get_formatted_address(self, obj):
if hasattr(obj, "location") and obj.location:
return obj.location.formatted_address
return ""
class CompanyOutputSerializer(serializers.Serializer):
"""Output serializer for company data."""
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
roles = serializers.ListField(child=serializers.CharField())
class ParkAreaOutputSerializer(serializers.Serializer):
"""Output serializer for park area data."""
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
description = serializers.CharField()
class ParkListOutputSerializer(serializers.Serializer):
"""Output serializer for park list view."""
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
status = serializers.CharField()
description = serializers.CharField()
# Statistics
average_rating = serializers.DecimalField(
max_digits=3, decimal_places=2, allow_null=True
)
coaster_count = serializers.IntegerField(allow_null=True)
ride_count = serializers.IntegerField(allow_null=True)
# Location (simplified for list view)
location = ParkLocationOutputSerializer(allow_null=True)
# Operator info
operator = CompanyOutputSerializer()
# Metadata
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
class ParkDetailOutputSerializer(serializers.Serializer):
"""Output serializer for park detail view."""
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
status = serializers.CharField()
description = serializers.CharField()
# Details
opening_date = serializers.DateField(allow_null=True)
closing_date = serializers.DateField(allow_null=True)
operating_season = serializers.CharField()
size_acres = serializers.DecimalField(
max_digits=10, decimal_places=2, allow_null=True
)
website = serializers.URLField()
# Statistics
average_rating = serializers.DecimalField(
max_digits=3, decimal_places=2, allow_null=True
)
coaster_count = serializers.IntegerField(allow_null=True)
ride_count = serializers.IntegerField(allow_null=True)
# Location (full details)
location = ParkLocationOutputSerializer(allow_null=True)
# Companies
operator = CompanyOutputSerializer()
property_owner = CompanyOutputSerializer(allow_null=True)
# Areas
areas = ParkAreaOutputSerializer(many=True)
# Metadata
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
class ParkCreateInputSerializer(serializers.Serializer):
"""Input serializer for creating parks."""
name = serializers.CharField(max_length=255)
description = serializers.CharField(allow_blank=True, default="")
status = serializers.ChoiceField(choices=Park.STATUS_CHOICES, default="OPERATING")
# Optional details
opening_date = serializers.DateField(required=False, allow_null=True)
closing_date = serializers.DateField(required=False, allow_null=True)
operating_season = serializers.CharField(
max_length=255, required=False, allow_blank=True
)
size_acres = serializers.DecimalField(
max_digits=10, decimal_places=2, required=False, allow_null=True
)
website = serializers.URLField(required=False, allow_blank=True)
# Required operator
operator_id = serializers.IntegerField()
# Optional property owner
property_owner_id = serializers.IntegerField(required=False, allow_null=True)
def validate(self, data):
"""Cross-field validation."""
opening_date = data.get("opening_date")
closing_date = data.get("closing_date")
if opening_date and closing_date and closing_date < opening_date:
raise serializers.ValidationError(
"Closing date cannot be before opening date"
)
return data
class ParkUpdateInputSerializer(serializers.Serializer):
"""Input serializer for updating parks."""
name = serializers.CharField(max_length=255, required=False)
description = serializers.CharField(allow_blank=True, required=False)
status = serializers.ChoiceField(choices=Park.STATUS_CHOICES, required=False)
# Optional details
opening_date = serializers.DateField(required=False, allow_null=True)
closing_date = serializers.DateField(required=False, allow_null=True)
operating_season = serializers.CharField(
max_length=255, required=False, allow_blank=True
)
size_acres = serializers.DecimalField(
max_digits=10, decimal_places=2, required=False, allow_null=True
)
website = serializers.URLField(required=False, allow_blank=True)
# Companies
operator_id = serializers.IntegerField(required=False)
property_owner_id = serializers.IntegerField(required=False, allow_null=True)
def validate(self, data):
"""Cross-field validation."""
opening_date = data.get("opening_date")
closing_date = data.get("closing_date")
if opening_date and closing_date and closing_date < opening_date:
raise serializers.ValidationError(
"Closing date cannot be before opening date"
)
return data
class ParkFilterInputSerializer(serializers.Serializer):
"""Input serializer for park filtering and search."""
# Search
search = serializers.CharField(required=False, allow_blank=True)
# Status filter
status = serializers.MultipleChoiceField(
choices=Park.STATUS_CHOICES, required=False
)
# Location filters
country = serializers.CharField(required=False, allow_blank=True)
state = serializers.CharField(required=False, allow_blank=True)
city = serializers.CharField(required=False, allow_blank=True)
# Rating filter
min_rating = serializers.DecimalField(
max_digits=3,
decimal_places=2,
required=False,
min_value=1,
max_value=10,
)
# Size filter
min_size_acres = serializers.DecimalField(
max_digits=10, decimal_places=2, required=False, min_value=0
)
max_size_acres = serializers.DecimalField(
max_digits=10, decimal_places=2, required=False, min_value=0
)
# Company filters
operator_id = serializers.IntegerField(required=False)
property_owner_id = serializers.IntegerField(required=False)
# Ordering
ordering = serializers.ChoiceField(
choices=[
"name",
"-name",
"opening_date",
"-opening_date",
"average_rating",
"-average_rating",
"coaster_count",
"-coaster_count",
"created_at",
"-created_at",
],
required=False,
default="name",
)
class ParkReviewOutputSerializer(serializers.Serializer):
"""Output serializer for park reviews."""
id = serializers.IntegerField()
rating = serializers.IntegerField()
title = serializers.CharField()
content = serializers.CharField()
visit_date = serializers.DateField()
created_at = serializers.DateTimeField()
# User info (limited for privacy)
user = serializers.SerializerMethodField()
def get_user(self, obj):
return {
"username": obj.user.username,
"display_name": obj.user.get_full_name() or obj.user.username,
}
class ParkStatsOutputSerializer(serializers.Serializer):
"""Output serializer for park statistics."""
total_parks = serializers.IntegerField()
operating_parks = serializers.IntegerField()
closed_parks = serializers.IntegerField()
under_construction = serializers.IntegerField()
# Averages
average_rating = serializers.DecimalField(
max_digits=3, decimal_places=2, allow_null=True
)
average_coaster_count = serializers.DecimalField(
max_digits=5, decimal_places=2, allow_null=True
)
# Top countries
top_countries = serializers.ListField(child=serializers.DictField())
# Recently added
recently_added_count = serializers.IntegerField()

View File

@@ -1,65 +0,0 @@
"""
URL configuration for Parks API following Django styleguide patterns.
"""
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import (
ParkListApi,
ParkDetailApi,
ParkCreateApi,
ParkUpdateApi,
ParkDeleteApi,
ParkApi,
)
app_name = "parks_api"
# Option 1: Separate ViewSets for each operation (more explicit)
router_separate = DefaultRouter()
router_separate.register(r"list", ParkListApi, basename="park-list")
router_separate.register(r"detail", ParkDetailApi, basename="park-detail")
router_separate.register(r"create", ParkCreateApi, basename="park-create")
router_separate.register(r"update", ParkUpdateApi, basename="park-update")
router_separate.register(r"delete", ParkDeleteApi, basename="park-delete")
# Option 2: Unified ViewSet (more conventional DRF)
router_unified = DefaultRouter()
router_unified.register(r"parks", ParkApi, basename="park")
# Use unified approach for cleaner URLs
urlpatterns = [
path("v1/", include(router_unified.urls)),
]
# Alternative manual URL patterns for more control
urlpatterns_manual = [
# List and create
path(
"v1/parks/",
ParkApi.as_view({"get": "list", "post": "create"}),
name="park-list",
),
# Stats endpoint
path("v1/parks/stats/", ParkApi.as_view({"get": "stats"}), name="park-stats"),
# Detail operations
path(
"v1/parks/<slug:slug>/",
ParkApi.as_view(
{
"get": "retrieve",
"put": "update",
"patch": "partial_update",
"delete": "destroy",
}
),
name="park-detail",
),
# Park reviews
path(
"v1/parks/<slug:slug>/reviews/",
ParkApi.as_view({"get": "reviews"}),
name="park-reviews",
),
]

View File

@@ -1,295 +0,0 @@
"""
Parks API views following Django styleguide patterns.
Uses ClassNameApi naming convention and proper Input/Output serializers.
"""
from rest_framework.decorators import action
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet
from rest_framework.permissions import (
IsAuthenticated,
IsAuthenticatedOrReadOnly,
)
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter, OrderingFilter
from apps.core.api.mixins import (
CreateApiMixin,
UpdateApiMixin,
ListApiMixin,
RetrieveApiMixin,
DestroyApiMixin,
)
from ..selectors import (
park_list_with_stats,
park_detail_optimized,
park_reviews_for_park,
park_statistics,
)
from ..services import ParkService
from .serializers import (
ParkListOutputSerializer,
ParkDetailOutputSerializer,
ParkCreateInputSerializer,
ParkUpdateInputSerializer,
ParkFilterInputSerializer,
ParkReviewOutputSerializer,
ParkStatsOutputSerializer,
)
class ParkListApi(ListApiMixin, GenericViewSet):
"""
API endpoint for listing parks with filtering and search.
GET /api/v1/parks/
"""
permission_classes = [IsAuthenticatedOrReadOnly]
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
search_fields = ["name", "description"]
ordering_fields = [
"name",
"opening_date",
"average_rating",
"coaster_count",
"created_at",
]
ordering = ["name"]
OutputSerializer = ParkListOutputSerializer
FilterSerializer = ParkFilterInputSerializer
def get_queryset(self):
"""Use selector to get optimized queryset."""
# Parse filter parameters
filter_serializer = self.FilterSerializer(data=self.request.query_params)
filter_serializer.is_valid(raise_exception=True)
filters = filter_serializer.validated_data
return park_list_with_stats(filters=filters)
@action(detail=False, methods=["get"])
def stats(self, request: Request) -> Response:
"""
Get park statistics.
GET /api/v1/parks/stats/
"""
stats = park_statistics()
serializer = ParkStatsOutputSerializer(stats)
return self.create_response(
data=serializer.data,
metadata={"cache_duration": 3600}, # 1 hour cache hint
)
class ParkDetailApi(RetrieveApiMixin, GenericViewSet):
"""
API endpoint for retrieving individual park details.
GET /api/v1/parks/{id}/
"""
permission_classes = [IsAuthenticatedOrReadOnly]
lookup_field = "slug"
OutputSerializer = ParkDetailOutputSerializer
def get_object(self):
"""Use selector for optimized detail query."""
slug = self.kwargs.get("slug")
return park_detail_optimized(slug=slug)
@action(detail=True, methods=["get"])
def reviews(self, request: Request, slug: str = None) -> Response:
"""
Get reviews for a specific park.
GET /api/v1/parks/{slug}/reviews/
"""
park = self.get_object()
reviews = park_reviews_for_park(park_id=park.id, limit=50)
serializer = ParkReviewOutputSerializer(reviews, many=True)
return self.create_response(
data=serializer.data,
metadata={"total_reviews": len(reviews), "park_name": park.name},
)
class ParkCreateApi(CreateApiMixin, GenericViewSet):
"""
API endpoint for creating parks.
POST /api/v1/parks/create/
"""
permission_classes = [IsAuthenticated]
InputSerializer = ParkCreateInputSerializer
OutputSerializer = ParkDetailOutputSerializer
def perform_create(self, **validated_data):
"""Create park using service layer."""
return ParkService.create_park(**validated_data)
class ParkUpdateApi(UpdateApiMixin, RetrieveApiMixin, GenericViewSet):
"""
API endpoint for updating parks.
PUT /api/v1/parks/{slug}/update/
PATCH /api/v1/parks/{slug}/update/
"""
permission_classes = [IsAuthenticated]
lookup_field = "slug"
InputSerializer = ParkUpdateInputSerializer
OutputSerializer = ParkDetailOutputSerializer
def get_object(self):
"""Use selector for optimized detail query."""
slug = self.kwargs.get("slug")
return park_detail_optimized(slug=slug)
def perform_update(self, instance, **validated_data):
"""Update park using service layer."""
return ParkService.update_park(park_id=instance.id, **validated_data)
class ParkDeleteApi(DestroyApiMixin, RetrieveApiMixin, GenericViewSet):
"""
API endpoint for deleting parks.
DELETE /api/v1/parks/{slug}/delete/
"""
permission_classes = [IsAuthenticated] # TODO: Add staff/admin permission
lookup_field = "slug"
def get_object(self):
"""Use selector for optimized detail query."""
slug = self.kwargs.get("slug")
return park_detail_optimized(slug=slug)
def perform_destroy(self, instance):
"""Delete park using service layer."""
ParkService.delete_park(park_id=instance.id)
# Unified API ViewSet (alternative approach)
class ParkApi(
CreateApiMixin,
UpdateApiMixin,
ListApiMixin,
RetrieveApiMixin,
DestroyApiMixin,
GenericViewSet,
):
"""
Unified API endpoint for parks with all CRUD operations.
GET /api/v1/parks/ - List parks
POST /api/v1/parks/ - Create park
GET /api/v1/parks/{slug}/ - Get park detail
PUT /api/v1/parks/{slug}/ - Update park
PATCH /api/v1/parks/{slug}/ - Partial update park
DELETE /api/v1/parks/{slug}/ - Delete park
"""
permission_classes = [IsAuthenticatedOrReadOnly]
lookup_field = "slug"
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
search_fields = ["name", "description"]
ordering_fields = [
"name",
"opening_date",
"average_rating",
"coaster_count",
"created_at",
]
ordering = ["name"]
# Serializers for different operations
InputSerializer = ParkCreateInputSerializer # Used for create
UpdateInputSerializer = ParkUpdateInputSerializer # Used for update
OutputSerializer = ParkDetailOutputSerializer # Used for retrieve
ListOutputSerializer = ParkListOutputSerializer # Used for list
FilterSerializer = ParkFilterInputSerializer
def get_queryset(self):
"""Use selector to get optimized queryset."""
if self.action == "list":
# Parse filter parameters for list view
filter_serializer = self.FilterSerializer(data=self.request.query_params)
filter_serializer.is_valid(raise_exception=True)
filters = filter_serializer.validated_data
return park_list_with_stats(**filters)
# For detail views, this won't be used since we override get_object
return []
def get_object(self):
"""Use selector for optimized detail query."""
slug = self.kwargs.get("slug")
return park_detail_optimized(slug=slug)
def get_output_serializer(self, *args, **kwargs):
"""Return appropriate output serializer based on action."""
if self.action == "list":
return self.ListOutputSerializer(*args, **kwargs)
return self.OutputSerializer(*args, **kwargs)
def get_input_serializer(self, *args, **kwargs):
"""Return appropriate input serializer based on action."""
if self.action in ["update", "partial_update"]:
return self.UpdateInputSerializer(*args, **kwargs)
return self.InputSerializer(*args, **kwargs)
def perform_create(self, **validated_data):
"""Create park using service layer."""
return ParkService.create_park(**validated_data)
def perform_update(self, instance, **validated_data):
"""Update park using service layer."""
return ParkService.update_park(park_id=instance.id, **validated_data)
def perform_destroy(self, instance):
"""Delete park using service layer."""
ParkService.delete_park(park_id=instance.id)
@action(detail=False, methods=["get"])
def stats(self, request: Request) -> Response:
"""
Get park statistics.
GET /api/v1/parks/stats/
"""
stats = park_statistics()
serializer = ParkStatsOutputSerializer(stats)
return self.create_response(
data=serializer.data, metadata={"cache_duration": 3600}
)
@action(detail=True, methods=["get"])
def reviews(self, request: Request, slug: str = None) -> Response:
"""
Get reviews for a specific park.
GET /api/v1/parks/{slug}/reviews/
"""
park = self.get_object()
reviews = park_reviews_for_park(park_id=park.id, limit=50)
serializer = ParkReviewOutputSerializer(reviews, many=True)
return self.create_response(
data=serializer.data,
metadata={"total_reviews": len(reviews), "park_name": park.name},
)

View File

@@ -0,0 +1,163 @@
# Generated by Django 5.2.5 on 2025-08-24 18:23
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("parks", "0005_merge_20250820_2020"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="company",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="company",
name="update_update",
),
pgtrigger.migrations.RemoveTrigger(
model_name="park",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="park",
name="update_update",
),
pgtrigger.migrations.RemoveTrigger(
model_name="parkarea",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="parkarea",
name="update_update",
),
pgtrigger.migrations.RemoveTrigger(
model_name="parkreview",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="parkreview",
name="update_update",
),
pgtrigger.migrations.AddTrigger(
model_name="company",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "parks_companyevent" ("created_at", "description", "founded_year", "id", "name", "parks_count", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rides_count", "roles", "slug", "updated_at", "website") VALUES (NEW."created_at", NEW."description", NEW."founded_year", NEW."id", NEW."name", NEW."parks_count", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rides_count", NEW."roles", NEW."slug", NEW."updated_at", NEW."website"); RETURN NULL;',
hash="0ed33eeca3344c43d8124d1f12e3acd3e6fdef02",
operation="INSERT",
pgid="pgtrigger_insert_insert_35b57",
table="parks_company",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="company",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "parks_companyevent" ("created_at", "description", "founded_year", "id", "name", "parks_count", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rides_count", "roles", "slug", "updated_at", "website") VALUES (NEW."created_at", NEW."description", NEW."founded_year", NEW."id", NEW."name", NEW."parks_count", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rides_count", NEW."roles", NEW."slug", NEW."updated_at", NEW."website"); RETURN NULL;',
hash="ce9d8347090a033d0a9550419b80a1c4a339216c",
operation="UPDATE",
pgid="pgtrigger_update_update_d3286",
table="parks_company",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="park",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "parks_parkevent" ("average_rating", "closing_date", "coaster_count", "created_at", "description", "id", "name", "opening_date", "operating_season", "operator_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "property_owner_id", "ride_count", "size_acres", "slug", "status", "updated_at", "website") VALUES (NEW."average_rating", NEW."closing_date", NEW."coaster_count", NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date", NEW."operating_season", NEW."operator_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."property_owner_id", NEW."ride_count", NEW."size_acres", NEW."slug", NEW."status", NEW."updated_at", NEW."website"); RETURN NULL;',
hash="0a4fc95f70ad65df16aa5eaf2939266260c49213",
operation="INSERT",
pgid="pgtrigger_insert_insert_66883",
table="parks_park",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="park",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "parks_parkevent" ("average_rating", "closing_date", "coaster_count", "created_at", "description", "id", "name", "opening_date", "operating_season", "operator_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "property_owner_id", "ride_count", "size_acres", "slug", "status", "updated_at", "website") VALUES (NEW."average_rating", NEW."closing_date", NEW."coaster_count", NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date", NEW."operating_season", NEW."operator_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."property_owner_id", NEW."ride_count", NEW."size_acres", NEW."slug", NEW."status", NEW."updated_at", NEW."website"); RETURN NULL;',
hash="24a57b318c082585e7c79da37677be7032600db9",
operation="UPDATE",
pgid="pgtrigger_update_update_19f56",
table="parks_park",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="parkarea",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "parks_parkareaevent" ("closing_date", "created_at", "description", "id", "name", "opening_date", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "updated_at") VALUES (NEW."closing_date", NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date", NEW."park_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."slug", NEW."updated_at"); RETURN NULL;',
hash="fa64ee07f872bf2214b2c1b638b028429752bac4",
operation="INSERT",
pgid="pgtrigger_insert_insert_13457",
table="parks_parkarea",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="parkarea",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "parks_parkareaevent" ("closing_date", "created_at", "description", "id", "name", "opening_date", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "updated_at") VALUES (NEW."closing_date", NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date", NEW."park_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."slug", NEW."updated_at"); RETURN NULL;',
hash="59fa84527a4fd0fa51685058b6037fa22163a095",
operation="UPDATE",
pgid="pgtrigger_update_update_6e5aa",
table="parks_parkarea",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="parkreview",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "parks_parkreviewevent" ("content", "created_at", "id", "is_published", "moderated_at", "moderated_by_id", "moderation_notes", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rating", "title", "updated_at", "user_id", "visit_date") VALUES (NEW."content", NEW."created_at", NEW."id", NEW."is_published", NEW."moderated_at", NEW."moderated_by_id", NEW."moderation_notes", NEW."park_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rating", NEW."title", NEW."updated_at", NEW."user_id", NEW."visit_date"); RETURN NULL;',
hash="fb501d2b3a0d903a03f1a1ff0ae8dd79b189791f",
operation="INSERT",
pgid="pgtrigger_insert_insert_a99bc",
table="parks_parkreview",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="parkreview",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "parks_parkreviewevent" ("content", "created_at", "id", "is_published", "moderated_at", "moderated_by_id", "moderation_notes", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rating", "title", "updated_at", "user_id", "visit_date") VALUES (NEW."content", NEW."created_at", NEW."id", NEW."is_published", NEW."moderated_at", NEW."moderated_by_id", NEW."moderation_notes", NEW."park_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rating", NEW."title", NEW."updated_at", NEW."user_id", NEW."visit_date"); RETURN NULL;',
hash="254ab0f9ccc0488ea313f1c50a2c35603f7ef02d",
operation="UPDATE",
pgid="pgtrigger_update_update_0e40d",
table="parks_parkreview",
when="AFTER",
),
),
),
]

View File

@@ -0,0 +1,232 @@
# Generated by Django 5.2.5 on 2025-08-24 19:25
import django.contrib.gis.db.models.fields
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("parks", "0006_remove_company_insert_insert_and_more"),
("pghistory", "0007_auto_20250421_0444"),
]
operations = [
migrations.CreateModel(
name="CompanyHeadquartersEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
(
"street_address",
models.CharField(
blank=True,
help_text="Mailing address if publicly available",
max_length=255,
),
),
(
"city",
models.CharField(help_text="Headquarters city", max_length=100),
),
(
"state_province",
models.CharField(
blank=True, help_text="State/Province/Region", max_length=100
),
),
(
"country",
models.CharField(
default="USA",
help_text="Country where headquarters is located",
max_length=100,
),
),
(
"postal_code",
models.CharField(
blank=True, help_text="ZIP or postal code", max_length=20
),
),
(
"mailing_address",
models.TextField(
blank=True,
help_text="Complete mailing address if different from basic address",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="ParkLocationEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
(
"point",
django.contrib.gis.db.models.fields.PointField(
blank=True,
help_text="Geographic coordinates (longitude, latitude)",
null=True,
srid=4326,
),
),
("street_address", models.CharField(blank=True, max_length=255)),
("city", models.CharField(max_length=100)),
("state", models.CharField(max_length=100)),
("country", models.CharField(default="USA", max_length=100)),
("postal_code", models.CharField(blank=True, max_length=20)),
("highway_exit", models.CharField(blank=True, max_length=100)),
("parking_notes", models.TextField(blank=True)),
("best_arrival_time", models.TimeField(blank=True, null=True)),
("seasonal_notes", models.TextField(blank=True)),
("osm_id", models.BigIntegerField(blank=True, null=True)),
(
"osm_type",
models.CharField(
blank=True,
help_text="Type of OpenStreetMap object (node, way, or relation)",
max_length=10,
),
),
],
options={
"abstract": False,
},
),
pgtrigger.migrations.AddTrigger(
model_name="companyheadquarters",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "parks_companyheadquartersevent" ("city", "company_id", "country", "created_at", "id", "mailing_address", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "postal_code", "state_province", "street_address", "updated_at") VALUES (NEW."city", NEW."company_id", NEW."country", NEW."created_at", NEW."id", NEW."mailing_address", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."postal_code", NEW."state_province", NEW."street_address", NEW."updated_at"); RETURN NULL;',
hash="acf99673091ec3717f404fdccefd6e0cb228c82e",
operation="INSERT",
pgid="pgtrigger_insert_insert_72259",
table="parks_companyheadquarters",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="companyheadquarters",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "parks_companyheadquartersevent" ("city", "company_id", "country", "created_at", "id", "mailing_address", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "postal_code", "state_province", "street_address", "updated_at") VALUES (NEW."city", NEW."company_id", NEW."country", NEW."created_at", NEW."id", NEW."mailing_address", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."postal_code", NEW."state_province", NEW."street_address", NEW."updated_at"); RETURN NULL;',
hash="bbbff3a1c9748d3ce1b2bf1b705d03ea40530c9b",
operation="UPDATE",
pgid="pgtrigger_update_update_c5392",
table="parks_companyheadquarters",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="parklocation",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "parks_parklocationevent" ("best_arrival_time", "city", "country", "highway_exit", "id", "osm_id", "osm_type", "park_id", "parking_notes", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "point", "postal_code", "seasonal_notes", "state", "street_address") VALUES (NEW."best_arrival_time", NEW."city", NEW."country", NEW."highway_exit", NEW."id", NEW."osm_id", NEW."osm_type", NEW."park_id", NEW."parking_notes", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."point", NEW."postal_code", NEW."seasonal_notes", NEW."state", NEW."street_address"); RETURN NULL;',
hash="fcc717a6f7408f959ac1f6406d4ba42b674e3c55",
operation="INSERT",
pgid="pgtrigger_insert_insert_f8c53",
table="parks_parklocation",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="parklocation",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "parks_parklocationevent" ("best_arrival_time", "city", "country", "highway_exit", "id", "osm_id", "osm_type", "park_id", "parking_notes", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "point", "postal_code", "seasonal_notes", "state", "street_address") VALUES (NEW."best_arrival_time", NEW."city", NEW."country", NEW."highway_exit", NEW."id", NEW."osm_id", NEW."osm_type", NEW."park_id", NEW."parking_notes", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."point", NEW."postal_code", NEW."seasonal_notes", NEW."state", NEW."street_address"); RETURN NULL;',
hash="dedb3937ae968cc9f2b30309fbf72d9168039efe",
operation="UPDATE",
pgid="pgtrigger_update_update_6dd0d",
table="parks_parklocation",
when="AFTER",
),
),
),
migrations.AddField(
model_name="companyheadquartersevent",
name="company",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="parks.company",
),
),
migrations.AddField(
model_name="companyheadquartersevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
migrations.AddField(
model_name="companyheadquartersevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="parks.companyheadquarters",
),
),
migrations.AddField(
model_name="parklocationevent",
name="park",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="parks.park",
),
),
migrations.AddField(
model_name="parklocationevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
migrations.AddField(
model_name="parklocationevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="parks.parklocation",
),
),
]

View File

@@ -40,12 +40,13 @@ class Company(TrackedModel):
def __str__(self):
return self.name
class Meta:
class Meta(TrackedModel.Meta):
app_label = "parks"
ordering = ["name"]
verbose_name_plural = "Companies"
@pghistory.track()
class CompanyHeadquarters(models.Model):
"""
Simple address storage for company headquarters without coordinate tracking.

View File

@@ -1,7 +1,9 @@
from django.contrib.gis.db import models
from django.contrib.gis.geos import Point
import pghistory
@pghistory.track()
class ParkLocation(models.Model):
"""
Represents the geographic location and address of a park, with PostGIS support.

View File

@@ -1,8 +1,10 @@
from django.contrib import admin
from django.contrib.gis.admin import GISModelAdmin
from django.utils.html import format_html
from .models.company import Company
from .models.rides import Ride
from .models.rides import Ride, RideModel, RollerCoasterStats
from .models.location import RideLocation
from .models.reviews import RideReview
class ManufacturerAdmin(admin.ModelAdmin):
@@ -74,23 +76,412 @@ class RideLocationAdmin(GISModelAdmin):
),
)
@admin.display(description="Latitude")
def latitude(self, obj):
return obj.latitude
latitude.short_description = "Latitude"
@admin.display(description="Longitude")
def longitude(self, obj):
return obj.longitude
longitude.short_description = "Longitude"
class RollerCoasterStatsInline(admin.StackedInline):
"""Inline admin for RollerCoasterStats"""
model = RollerCoasterStats
extra = 0
fields = (
("height_ft", "length_ft", "speed_mph"),
("track_material", "roller_coaster_type"),
("launch_type", "inversions"),
("max_drop_height_ft", "ride_time_seconds"),
("train_style", "trains_count"),
("cars_per_train", "seats_per_car"),
)
classes = ("collapse",)
@admin.register(Ride)
class RideAdmin(admin.ModelAdmin):
"""Enhanced Ride admin with location inline"""
"""Enhanced Ride admin with location and coaster stats inlines"""
inlines = [RideLocationInline]
list_display = (
"name",
"park",
"category_display",
"manufacturer",
"status",
"opening_date",
"average_rating",
)
list_filter = (
"category",
"status",
"park",
"manufacturer",
"designer",
"opening_date",
)
search_fields = (
"name",
"description",
"park__name",
"manufacturer__name",
"designer__name",
)
readonly_fields = ("created_at", "updated_at")
prepopulated_fields = {"slug": ("name",)}
inlines = [RideLocationInline, RollerCoasterStatsInline]
date_hierarchy = "opening_date"
ordering = ("park", "name")
fieldsets = (
(
"Basic Information",
{
"fields": (
"name",
"slug",
"description",
"park",
"park_area",
"category",
)
},
),
(
"Companies",
{
"fields": (
"manufacturer",
"designer",
"ride_model",
)
},
),
(
"Status & Dates",
{
"fields": (
"status",
"post_closing_status",
"opening_date",
"closing_date",
"status_since",
)
},
),
(
"Ride Specifications",
{
"fields": (
"min_height_in",
"max_height_in",
"capacity_per_hour",
"ride_duration_seconds",
"average_rating",
),
"classes": ("collapse",),
},
),
(
"Metadata",
{
"fields": ("created_at", "updated_at"),
"classes": ("collapse",),
},
),
)
@admin.display(description="Category")
def category_display(self, obj):
"""Display category with full name"""
return dict(obj._meta.get_field("category").choices).get(
obj.category, obj.category
)
@admin.register(RideModel)
class RideModelAdmin(admin.ModelAdmin):
"""Admin interface for ride models"""
list_display = (
"name",
"manufacturer",
"category_display",
"ride_count",
)
list_filter = (
"manufacturer",
"category",
)
search_fields = (
"name",
"description",
"manufacturer__name",
)
ordering = ("manufacturer", "name")
fieldsets = (
(
"Model Information",
{
"fields": (
"name",
"manufacturer",
"category",
"description",
)
},
),
)
@admin.display(description="Category")
def category_display(self, obj):
"""Display category with full name"""
return dict(obj._meta.get_field("category").choices).get(
obj.category, obj.category
)
@admin.display(description="Installations")
def ride_count(self, obj):
"""Display number of ride installations"""
return obj.rides.count()
@admin.register(RollerCoasterStats)
class RollerCoasterStatsAdmin(admin.ModelAdmin):
"""Admin interface for roller coaster statistics"""
list_display = (
"ride",
"height_ft",
"speed_mph",
"length_ft",
"inversions",
"track_material",
"roller_coaster_type",
)
list_filter = (
"track_material",
"roller_coaster_type",
"launch_type",
"inversions",
)
search_fields = (
"ride__name",
"ride__park__name",
"track_type",
"train_style",
)
readonly_fields = ("calculated_capacity",)
fieldsets = (
(
"Basic Stats",
{
"fields": (
"ride",
"height_ft",
"length_ft",
"speed_mph",
"max_drop_height_ft",
)
},
),
(
"Track & Design",
{
"fields": (
"track_material",
"track_type",
"roller_coaster_type",
"launch_type",
"inversions",
)
},
),
(
"Operation Details",
{
"fields": (
"ride_time_seconds",
"train_style",
"trains_count",
"cars_per_train",
"seats_per_car",
"calculated_capacity",
),
"classes": ("collapse",),
},
),
)
@admin.display(description="Calculated Capacity")
def calculated_capacity(self, obj):
"""Calculate theoretical hourly capacity"""
if all(
[
obj.trains_count,
obj.cars_per_train,
obj.seats_per_car,
obj.ride_time_seconds,
]
):
total_seats = obj.trains_count * obj.cars_per_train * obj.seats_per_car
# Add 2 min loading time
cycles_per_hour = 3600 / (obj.ride_time_seconds + 120)
return f"{int(total_seats * cycles_per_hour)} riders/hour"
return "N/A"
@admin.register(RideReview)
class RideReviewAdmin(admin.ModelAdmin):
"""Admin interface for ride reviews"""
list_display = (
"ride",
"user",
"rating",
"title",
"visit_date",
"is_published",
"created_at",
"moderation_status",
)
list_filter = (
"rating",
"is_published",
"visit_date",
"created_at",
"ride__park",
"moderated_by",
)
search_fields = (
"title",
"content",
"user__username",
"ride__name",
"ride__park__name",
)
readonly_fields = ("created_at", "updated_at")
date_hierarchy = "created_at"
ordering = ("-created_at",)
fieldsets = (
(
"Review Details",
{
"fields": (
"user",
"ride",
"rating",
"title",
"content",
"visit_date",
)
},
),
(
"Publication Status",
{
"fields": ("is_published",),
},
),
(
"Moderation",
{
"fields": (
"moderated_by",
"moderated_at",
"moderation_notes",
),
"classes": ("collapse",),
},
),
(
"Metadata",
{
"fields": ("created_at", "updated_at"),
"classes": ("collapse",),
},
),
)
@admin.display(description="Moderation Status", boolean=True)
def moderation_status(self, obj):
"""Display moderation status with color coding"""
if obj.moderated_by:
return format_html(
'<span style="color: {};">{}</span>',
"green" if obj.is_published else "red",
"Approved" if obj.is_published else "Rejected",
)
return format_html('<span style="color: orange;">Pending</span>')
def save_model(self, request, obj, form, change):
"""Auto-set moderation info when status changes"""
if change and "is_published" in form.changed_data:
from django.utils import timezone
obj.moderated_by = request.user
obj.moderated_at = timezone.now()
super().save_model(request, obj, form, change)
@admin.register(Company)
class CompanyAdmin(admin.ModelAdmin):
"""Enhanced Company admin for rides app"""
list_display = (
"name",
"roles_display",
"website",
"founded_date",
"rides_count",
"coasters_count",
)
list_filter = ("roles", "founded_date")
search_fields = ("name", "description")
readonly_fields = ("created_at", "updated_at")
prepopulated_fields = {"slug": ("name",)}
fieldsets = (
(
"Basic Information",
{
"fields": (
"name",
"slug",
"roles",
"description",
"website",
)
},
),
(
"Company Details",
{
"fields": (
"founded_date",
"rides_count",
"coasters_count",
)
},
),
(
"Metadata",
{
"fields": ("created_at", "updated_at"),
"classes": ("collapse",),
},
),
)
@admin.display(description="Roles")
def roles_display(self, obj):
"""Display roles as a formatted string"""
return ", ".join(obj.roles) if obj.roles else "No roles"
admin.site.register(Company)
admin.site.register(Ride, RideAdmin)
admin.site.register(RideLocation, RideLocationAdmin)

View File

@@ -1 +0,0 @@
# Rides API module

View File

@@ -1,345 +0,0 @@
"""
Serializers for Rides API following Django styleguide patterns.
"""
from rest_framework import serializers
from ..models import Ride
class RideModelOutputSerializer(serializers.Serializer):
"""Output serializer for ride model data."""
id = serializers.IntegerField()
name = serializers.CharField()
description = serializers.CharField()
category = serializers.CharField()
manufacturer = serializers.SerializerMethodField()
def get_manufacturer(self, obj):
if obj.manufacturer:
return {
"id": obj.manufacturer.id,
"name": obj.manufacturer.name,
"slug": obj.manufacturer.slug,
}
return None
class RideParkOutputSerializer(serializers.Serializer):
"""Output serializer for ride's park data."""
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
class RideListOutputSerializer(serializers.Serializer):
"""Output serializer for ride list view."""
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
category = serializers.CharField()
status = serializers.CharField()
description = serializers.CharField()
# Park info
park = RideParkOutputSerializer()
# Statistics
average_rating = serializers.DecimalField(
max_digits=3, decimal_places=2, allow_null=True
)
capacity_per_hour = serializers.IntegerField(allow_null=True)
# Dates
opening_date = serializers.DateField(allow_null=True)
closing_date = serializers.DateField(allow_null=True)
# Metadata
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
class RideDetailOutputSerializer(serializers.Serializer):
"""Output serializer for ride detail view."""
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
category = serializers.CharField()
status = serializers.CharField()
post_closing_status = serializers.CharField(allow_null=True)
description = serializers.CharField()
# Park info
park = RideParkOutputSerializer()
park_area = serializers.SerializerMethodField()
# Dates
opening_date = serializers.DateField(allow_null=True)
closing_date = serializers.DateField(allow_null=True)
status_since = serializers.DateField(allow_null=True)
# Physical specs
min_height_in = serializers.IntegerField(allow_null=True)
max_height_in = serializers.IntegerField(allow_null=True)
capacity_per_hour = serializers.IntegerField(allow_null=True)
ride_duration_seconds = serializers.IntegerField(allow_null=True)
# Statistics
average_rating = serializers.DecimalField(
max_digits=3, decimal_places=2, allow_null=True
)
# Companies
manufacturer = serializers.SerializerMethodField()
designer = serializers.SerializerMethodField()
# Model
ride_model = RideModelOutputSerializer(allow_null=True)
# Metadata
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
def get_park_area(self, obj):
if obj.park_area:
return {
"id": obj.park_area.id,
"name": obj.park_area.name,
"slug": obj.park_area.slug,
}
return None
def get_manufacturer(self, obj):
if obj.manufacturer:
return {
"id": obj.manufacturer.id,
"name": obj.manufacturer.name,
"slug": obj.manufacturer.slug,
}
return None
def get_designer(self, obj):
if obj.designer:
return {
"id": obj.designer.id,
"name": obj.designer.name,
"slug": obj.designer.slug,
}
return None
class RideCreateInputSerializer(serializers.Serializer):
"""Input serializer for creating rides."""
name = serializers.CharField(max_length=255)
description = serializers.CharField(allow_blank=True, default="")
category = serializers.ChoiceField(choices=Ride.CATEGORY_CHOICES)
status = serializers.ChoiceField(choices=Ride.STATUS_CHOICES, default="OPERATING")
# Required park
park_id = serializers.IntegerField()
# Optional area
park_area_id = serializers.IntegerField(required=False, allow_null=True)
# Optional dates
opening_date = serializers.DateField(required=False, allow_null=True)
closing_date = serializers.DateField(required=False, allow_null=True)
status_since = serializers.DateField(required=False, allow_null=True)
# Optional specs
min_height_in = serializers.IntegerField(
required=False, allow_null=True, min_value=30, max_value=90
)
max_height_in = serializers.IntegerField(
required=False, allow_null=True, min_value=30, max_value=90
)
capacity_per_hour = serializers.IntegerField(
required=False, allow_null=True, min_value=1
)
ride_duration_seconds = serializers.IntegerField(
required=False, allow_null=True, min_value=1
)
# Optional companies
manufacturer_id = serializers.IntegerField(required=False, allow_null=True)
designer_id = serializers.IntegerField(required=False, allow_null=True)
# Optional model
ride_model_id = serializers.IntegerField(required=False, allow_null=True)
def validate(self, data):
"""Cross-field validation."""
# Date validation
opening_date = data.get("opening_date")
closing_date = data.get("closing_date")
if opening_date and closing_date and closing_date < opening_date:
raise serializers.ValidationError(
"Closing date cannot be before opening date"
)
# Height validation
min_height = data.get("min_height_in")
max_height = data.get("max_height_in")
if min_height and max_height and min_height > max_height:
raise serializers.ValidationError(
"Minimum height cannot be greater than maximum height"
)
return data
class RideUpdateInputSerializer(serializers.Serializer):
"""Input serializer for updating rides."""
name = serializers.CharField(max_length=255, required=False)
description = serializers.CharField(allow_blank=True, required=False)
category = serializers.ChoiceField(choices=Ride.CATEGORY_CHOICES, required=False)
status = serializers.ChoiceField(choices=Ride.STATUS_CHOICES, required=False)
post_closing_status = serializers.ChoiceField(
choices=Ride.POST_CLOSING_STATUS_CHOICES,
required=False,
allow_null=True,
)
# Park and area
park_id = serializers.IntegerField(required=False)
park_area_id = serializers.IntegerField(required=False, allow_null=True)
# Dates
opening_date = serializers.DateField(required=False, allow_null=True)
closing_date = serializers.DateField(required=False, allow_null=True)
status_since = serializers.DateField(required=False, allow_null=True)
# Specs
min_height_in = serializers.IntegerField(
required=False, allow_null=True, min_value=30, max_value=90
)
max_height_in = serializers.IntegerField(
required=False, allow_null=True, min_value=30, max_value=90
)
capacity_per_hour = serializers.IntegerField(
required=False, allow_null=True, min_value=1
)
ride_duration_seconds = serializers.IntegerField(
required=False, allow_null=True, min_value=1
)
# Companies
manufacturer_id = serializers.IntegerField(required=False, allow_null=True)
designer_id = serializers.IntegerField(required=False, allow_null=True)
# Model
ride_model_id = serializers.IntegerField(required=False, allow_null=True)
def validate(self, data):
"""Cross-field validation."""
# Date validation
opening_date = data.get("opening_date")
closing_date = data.get("closing_date")
if opening_date and closing_date and closing_date < opening_date:
raise serializers.ValidationError(
"Closing date cannot be before opening date"
)
# Height validation
min_height = data.get("min_height_in")
max_height = data.get("max_height_in")
if min_height and max_height and min_height > max_height:
raise serializers.ValidationError(
"Minimum height cannot be greater than maximum height"
)
return data
class RideFilterInputSerializer(serializers.Serializer):
"""Input serializer for ride filtering and search."""
# Search
search = serializers.CharField(required=False, allow_blank=True)
# Category filter
category = serializers.MultipleChoiceField(
choices=Ride.CATEGORY_CHOICES, required=False
)
# Status filter
status = serializers.MultipleChoiceField(
choices=Ride.STATUS_CHOICES, required=False
)
# Park filter
park_id = serializers.IntegerField(required=False)
park_slug = serializers.CharField(required=False, allow_blank=True)
# Company filters
manufacturer_id = serializers.IntegerField(required=False)
designer_id = serializers.IntegerField(required=False)
# Rating filter
min_rating = serializers.DecimalField(
max_digits=3,
decimal_places=2,
required=False,
min_value=1,
max_value=10,
)
# Height filters
min_height_requirement = serializers.IntegerField(required=False)
max_height_requirement = serializers.IntegerField(required=False)
# Capacity filter
min_capacity = serializers.IntegerField(required=False)
# Ordering
ordering = serializers.ChoiceField(
choices=[
"name",
"-name",
"opening_date",
"-opening_date",
"average_rating",
"-average_rating",
"capacity_per_hour",
"-capacity_per_hour",
"created_at",
"-created_at",
],
required=False,
default="name",
)
class RideStatsOutputSerializer(serializers.Serializer):
"""Output serializer for ride statistics."""
total_rides = serializers.IntegerField()
operating_rides = serializers.IntegerField()
closed_rides = serializers.IntegerField()
under_construction = serializers.IntegerField()
# By category
rides_by_category = serializers.DictField()
# Averages
average_rating = serializers.DecimalField(
max_digits=3, decimal_places=2, allow_null=True
)
average_capacity = serializers.DecimalField(
max_digits=8, decimal_places=2, allow_null=True
)
# Top manufacturers
top_manufacturers = serializers.ListField(child=serializers.DictField())
# Recently added
recently_added_count = serializers.IntegerField()

View File

@@ -1,14 +0,0 @@
"""
URL configuration for Rides API following Django styleguide patterns.
"""
# Note: We'll create the views file after this
# from .views import RideApi
app_name = "rides_api"
# Placeholder for future implementation
urlpatterns = [
# Will be implemented in next phase
# path('v1/', include(router.urls)),
]

View File

@@ -0,0 +1,89 @@
# Generated by Django 5.2.5 on 2025-08-24 18:23
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("rides", "0002_add_business_constraints"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="company",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="company",
name="update_update",
),
pgtrigger.migrations.RemoveTrigger(
model_name="ridereview",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="ridereview",
name="update_update",
),
pgtrigger.migrations.AddTrigger(
model_name="company",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_companyevent" ("coasters_count", "created_at", "description", "founded_date", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rides_count", "roles", "slug", "updated_at", "website") VALUES (NEW."coasters_count", NEW."created_at", NEW."description", NEW."founded_date", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rides_count", NEW."roles", NEW."slug", NEW."updated_at", NEW."website"); RETURN NULL;',
hash="8229e39396673e3801d570ad8d1c602b528fadc1",
operation="INSERT",
pgid="pgtrigger_insert_insert_e7194",
table="rides_company",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="company",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_companyevent" ("coasters_count", "created_at", "description", "founded_date", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rides_count", "roles", "slug", "updated_at", "website") VALUES (NEW."coasters_count", NEW."created_at", NEW."description", NEW."founded_date", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rides_count", NEW."roles", NEW."slug", NEW."updated_at", NEW."website"); RETURN NULL;',
hash="9d4d58e00963bf10b3cc428ef609044104366655",
operation="UPDATE",
pgid="pgtrigger_update_update_456a8",
table="rides_company",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ridereview",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_ridereviewevent" ("content", "created_at", "id", "is_published", "moderated_at", "moderated_by_id", "moderation_notes", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rating", "ride_id", "title", "updated_at", "user_id", "visit_date") VALUES (NEW."content", NEW."created_at", NEW."id", NEW."is_published", NEW."moderated_at", NEW."moderated_by_id", NEW."moderation_notes", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rating", NEW."ride_id", NEW."title", NEW."updated_at", NEW."user_id", NEW."visit_date"); RETURN NULL;',
hash="0d6021859fef528429d7d6028439c08de6040e52",
operation="INSERT",
pgid="pgtrigger_insert_insert_33237",
table="rides_ridereview",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ridereview",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_ridereviewevent" ("content", "created_at", "id", "is_published", "moderated_at", "moderated_by_id", "moderation_notes", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rating", "ride_id", "title", "updated_at", "user_id", "visit_date") VALUES (NEW."content", NEW."created_at", NEW."id", NEW."is_published", NEW."moderated_at", NEW."moderated_by_id", NEW."moderation_notes", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rating", NEW."ride_id", NEW."title", NEW."updated_at", NEW."user_id", NEW."visit_date"); RETURN NULL;',
hash="b0acd6ed16f909a42f9bedd975c7f385d0868d32",
operation="UPDATE",
pgid="pgtrigger_update_update_90298",
table="rides_ridereview",
when="AFTER",
),
),
),
]

View File

@@ -0,0 +1,470 @@
# Generated by Django 5.2.5 on 2025-08-24 19:11
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("parks", "0006_remove_company_insert_insert_and_more"),
("pghistory", "0007_auto_20250421_0444"),
("rides", "0003_remove_company_insert_insert_and_more"),
]
operations = [
migrations.CreateModel(
name="RideEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=255)),
("slug", models.SlugField(db_index=False, max_length=255)),
("description", models.TextField(blank=True)),
(
"category",
models.CharField(
blank=True,
choices=[
("", "Select ride type"),
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("TR", "Transport"),
("OT", "Other"),
],
default="",
max_length=2,
),
),
(
"status",
models.CharField(
choices=[
("", "Select status"),
("OPERATING", "Operating"),
("CLOSED_TEMP", "Temporarily Closed"),
("SBNO", "Standing But Not Operating"),
("CLOSING", "Closing"),
("CLOSED_PERM", "Permanently Closed"),
("UNDER_CONSTRUCTION", "Under Construction"),
("DEMOLISHED", "Demolished"),
("RELOCATED", "Relocated"),
],
default="OPERATING",
max_length=20,
),
),
(
"post_closing_status",
models.CharField(
blank=True,
choices=[
("SBNO", "Standing But Not Operating"),
("CLOSED_PERM", "Permanently Closed"),
],
help_text="Status to change to after closing date",
max_length=20,
null=True,
),
),
("opening_date", models.DateField(blank=True, null=True)),
("closing_date", models.DateField(blank=True, null=True)),
("status_since", models.DateField(blank=True, null=True)),
("min_height_in", models.PositiveIntegerField(blank=True, null=True)),
("max_height_in", models.PositiveIntegerField(blank=True, null=True)),
(
"capacity_per_hour",
models.PositiveIntegerField(blank=True, null=True),
),
(
"ride_duration_seconds",
models.PositiveIntegerField(blank=True, null=True),
),
(
"average_rating",
models.DecimalField(
blank=True, decimal_places=2, max_digits=3, null=True
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="RideModelEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=255)),
("description", models.TextField(blank=True)),
(
"category",
models.CharField(
blank=True,
choices=[
("", "Select ride type"),
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("TR", "Transport"),
("OT", "Other"),
],
default="",
max_length=2,
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="RollerCoasterStatsEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
(
"height_ft",
models.DecimalField(
blank=True, decimal_places=2, max_digits=6, null=True
),
),
(
"length_ft",
models.DecimalField(
blank=True, decimal_places=2, max_digits=7, null=True
),
),
(
"speed_mph",
models.DecimalField(
blank=True, decimal_places=2, max_digits=5, null=True
),
),
("inversions", models.PositiveIntegerField(default=0)),
(
"ride_time_seconds",
models.PositiveIntegerField(blank=True, null=True),
),
("track_type", models.CharField(blank=True, max_length=255)),
(
"track_material",
models.CharField(
blank=True,
choices=[
("STEEL", "Steel"),
("WOOD", "Wood"),
("HYBRID", "Hybrid"),
],
default="STEEL",
max_length=20,
),
),
(
"roller_coaster_type",
models.CharField(
blank=True,
choices=[
("SITDOWN", "Sit Down"),
("INVERTED", "Inverted"),
("FLYING", "Flying"),
("STANDUP", "Stand Up"),
("WING", "Wing"),
("DIVE", "Dive"),
("FAMILY", "Family"),
("WILD_MOUSE", "Wild Mouse"),
("SPINNING", "Spinning"),
("FOURTH_DIMENSION", "4th Dimension"),
("OTHER", "Other"),
],
default="SITDOWN",
max_length=20,
),
),
(
"max_drop_height_ft",
models.DecimalField(
blank=True, decimal_places=2, max_digits=6, null=True
),
),
(
"launch_type",
models.CharField(
choices=[
("CHAIN", "Chain Lift"),
("LSM", "LSM Launch"),
("HYDRAULIC", "Hydraulic Launch"),
("GRAVITY", "Gravity"),
("OTHER", "Other"),
],
default="CHAIN",
max_length=20,
),
),
("train_style", models.CharField(blank=True, max_length=255)),
("trains_count", models.PositiveIntegerField(blank=True, null=True)),
("cars_per_train", models.PositiveIntegerField(blank=True, null=True)),
("seats_per_car", models.PositiveIntegerField(blank=True, null=True)),
],
options={
"abstract": False,
},
),
pgtrigger.migrations.AddTrigger(
model_name="ride",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_rideevent" ("average_rating", "capacity_per_hour", "category", "closing_date", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "park_area_id", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "slug", "status", "status_since", "updated_at") VALUES (NEW."average_rating", NEW."capacity_per_hour", NEW."category", NEW."closing_date", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."park_area_id", NEW."park_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at"); RETURN NULL;',
hash="870aa867ae6892f187dc0382e4a6833b5d1267c5",
operation="INSERT",
pgid="pgtrigger_insert_insert_52074",
table="rides_ride",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ride",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_rideevent" ("average_rating", "capacity_per_hour", "category", "closing_date", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "park_area_id", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "slug", "status", "status_since", "updated_at") VALUES (NEW."average_rating", NEW."capacity_per_hour", NEW."category", NEW."closing_date", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."park_area_id", NEW."park_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at"); RETURN NULL;',
hash="8bafb42256ee98b4517ae4d39d0e774111794fea",
operation="UPDATE",
pgid="pgtrigger_update_update_4917a",
table="rides_ride",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ridemodel",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_ridemodelevent" ("category", "created_at", "description", "id", "manufacturer_id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "updated_at") VALUES (NEW."category", NEW."created_at", NEW."description", NEW."id", NEW."manufacturer_id", NEW."name", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."updated_at"); RETURN NULL;',
hash="e9e3c3ec4cb2400b363035534c580c94a3bb1d53",
operation="INSERT",
pgid="pgtrigger_insert_insert_0aaee",
table="rides_ridemodel",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ridemodel",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_ridemodelevent" ("category", "created_at", "description", "id", "manufacturer_id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "updated_at") VALUES (NEW."category", NEW."created_at", NEW."description", NEW."id", NEW."manufacturer_id", NEW."name", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."updated_at"); RETURN NULL;',
hash="4c8073b866beac402ace852e23974fcb01d24267",
operation="UPDATE",
pgid="pgtrigger_update_update_0ca1a",
table="rides_ridemodel",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="rollercoasterstats",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_rollercoasterstatsevent" ("cars_per_train", "height_ft", "id", "inversions", "launch_type", "length_ft", "max_drop_height_ft", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_id", "ride_time_seconds", "roller_coaster_type", "seats_per_car", "speed_mph", "track_material", "track_type", "train_style", "trains_count") VALUES (NEW."cars_per_train", NEW."height_ft", NEW."id", NEW."inversions", NEW."launch_type", NEW."length_ft", NEW."max_drop_height_ft", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."ride_id", NEW."ride_time_seconds", NEW."roller_coaster_type", NEW."seats_per_car", NEW."speed_mph", NEW."track_material", NEW."track_type", NEW."train_style", NEW."trains_count"); RETURN NULL;',
hash="529f2cf3bb62b57c85123143523475c1999099ec",
operation="INSERT",
pgid="pgtrigger_insert_insert_96f8b",
table="rides_rollercoasterstats",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="rollercoasterstats",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_rollercoasterstatsevent" ("cars_per_train", "height_ft", "id", "inversions", "launch_type", "length_ft", "max_drop_height_ft", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_id", "ride_time_seconds", "roller_coaster_type", "seats_per_car", "speed_mph", "track_material", "track_type", "train_style", "trains_count") VALUES (NEW."cars_per_train", NEW."height_ft", NEW."id", NEW."inversions", NEW."launch_type", NEW."length_ft", NEW."max_drop_height_ft", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."ride_id", NEW."ride_time_seconds", NEW."roller_coaster_type", NEW."seats_per_car", NEW."speed_mph", NEW."track_material", NEW."track_type", NEW."train_style", NEW."trains_count"); RETURN NULL;',
hash="3c9d3cb53ac46a2f4b2a27a63c5ed17a18de9827",
operation="UPDATE",
pgid="pgtrigger_update_update_24e8a",
table="rides_rollercoasterstats",
when="AFTER",
),
),
),
migrations.AddField(
model_name="rideevent",
name="designer",
field=models.ForeignKey(
blank=True,
db_constraint=False,
limit_choices_to={"roles__contains": ["DESIGNER"]},
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.company",
),
),
migrations.AddField(
model_name="rideevent",
name="manufacturer",
field=models.ForeignKey(
blank=True,
db_constraint=False,
limit_choices_to={"roles__contains": ["MANUFACTURER"]},
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.company",
),
),
migrations.AddField(
model_name="rideevent",
name="park",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="parks.park",
),
),
migrations.AddField(
model_name="rideevent",
name="park_area",
field=models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="parks.parkarea",
),
),
migrations.AddField(
model_name="rideevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
migrations.AddField(
model_name="rideevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="rides.ride",
),
),
migrations.AddField(
model_name="rideevent",
name="ride_model",
field=models.ForeignKey(
blank=True,
db_constraint=False,
help_text="The specific model/type of this ride",
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.ridemodel",
),
),
migrations.AddField(
model_name="ridemodelevent",
name="manufacturer",
field=models.ForeignKey(
blank=True,
db_constraint=False,
limit_choices_to={"roles__contains": ["MANUFACTURER"]},
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.company",
),
),
migrations.AddField(
model_name="ridemodelevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
migrations.AddField(
model_name="ridemodelevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="rides.ridemodel",
),
),
migrations.AddField(
model_name="rollercoasterstatsevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
migrations.AddField(
model_name="rollercoasterstatsevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="rides.rollercoasterstats",
),
),
migrations.AddField(
model_name="rollercoasterstatsevent",
name="ride",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.ride",
),
),
]

View File

@@ -0,0 +1,128 @@
# Generated by Django 5.2.5 on 2025-08-24 19:25
import django.contrib.gis.db.models.fields
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pghistory", "0007_auto_20250421_0444"),
("rides", "0004_rideevent_ridemodelevent_rollercoasterstatsevent_and_more"),
]
operations = [
migrations.CreateModel(
name="RideLocationEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
(
"point",
django.contrib.gis.db.models.fields.PointField(
blank=True,
help_text="Geographic coordinates for ride location (longitude, latitude)",
null=True,
srid=4326,
),
),
(
"park_area",
models.CharField(
blank=True,
help_text="Themed area or land within the park (e.g., 'Frontierland', 'Tomorrowland')",
max_length=100,
),
),
(
"notes",
models.TextField(blank=True, help_text="General location notes"),
),
(
"entrance_notes",
models.TextField(
blank=True,
help_text="Directions to ride entrance, queue location, or navigation tips",
),
),
(
"accessibility_notes",
models.TextField(
blank=True,
help_text="Information about accessible entrances, wheelchair access, etc.",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"abstract": False,
},
),
pgtrigger.migrations.AddTrigger(
model_name="ridelocation",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_ridelocationevent" ("accessibility_notes", "created_at", "entrance_notes", "id", "notes", "park_area", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "point", "ride_id", "updated_at") VALUES (NEW."accessibility_notes", NEW."created_at", NEW."entrance_notes", NEW."id", NEW."notes", NEW."park_area", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."point", NEW."ride_id", NEW."updated_at"); RETURN NULL;',
hash="04c4c3aa17d4ef852d52b40d1dba4cd7372d5e29",
operation="INSERT",
pgid="pgtrigger_insert_insert_b66c2",
table="rides_ridelocation",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ridelocation",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_ridelocationevent" ("accessibility_notes", "created_at", "entrance_notes", "id", "notes", "park_area", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "point", "ride_id", "updated_at") VALUES (NEW."accessibility_notes", NEW."created_at", NEW."entrance_notes", NEW."id", NEW."notes", NEW."park_area", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."point", NEW."ride_id", NEW."updated_at"); RETURN NULL;',
hash="7073b4517d00b884b2f3fddf89caeefaa64058ad",
operation="UPDATE",
pgid="pgtrigger_update_update_402ba",
table="rides_ridelocation",
when="AFTER",
),
),
),
migrations.AddField(
model_name="ridelocationevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
migrations.AddField(
model_name="ridelocationevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="rides.ridelocation",
),
),
migrations.AddField(
model_name="ridelocationevent",
name="ride",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.ride",
),
),
]

View File

@@ -1,8 +1,10 @@
from django.contrib.gis.db import models as gis_models
from django.db import models
from django.contrib.gis.geos import Point
import pghistory
@pghistory.track()
class RideLocation(models.Model):
"""
Lightweight location tracking for individual rides within parks.

View File

@@ -3,6 +3,7 @@ from django.utils.text import slugify
from django.contrib.contenttypes.fields import GenericRelation
from apps.core.models import TrackedModel
from .company import Company
import pghistory
# Shared choices that will be used by multiple models
CATEGORY_CHOICES = [
@@ -19,6 +20,7 @@ CATEGORY_CHOICES = [
Categories = CATEGORY_CHOICES
@pghistory.track()
class RideModel(TrackedModel):
"""
Represents a specific model/type of ride that can be manufactured by different
@@ -40,7 +42,7 @@ class RideModel(TrackedModel):
max_length=2, choices=CATEGORY_CHOICES, default="", blank=True
)
class Meta:
class Meta(TrackedModel.Meta):
ordering = ["manufacturer", "name"]
unique_together = ["manufacturer", "name"]
@@ -52,6 +54,7 @@ class RideModel(TrackedModel):
)
@pghistory.track()
class Ride(TrackedModel):
"""Model for individual ride installations at parks"""
@@ -134,7 +137,7 @@ class Ride(TrackedModel):
)
photos = GenericRelation("media.Photo")
class Meta:
class Meta(TrackedModel.Meta):
ordering = ["name"]
unique_together = ["park", "slug"]
constraints = [
@@ -203,6 +206,7 @@ class Ride(TrackedModel):
super().save(*args, **kwargs)
@pghistory.track()
class RollerCoasterStats(models.Model):
"""Model for tracking roller coaster specific statistics"""

View File

@@ -66,6 +66,7 @@ DJANGO_APPS = [
THIRD_PARTY_APPS = [
"rest_framework", # Django REST Framework
"rest_framework.authtoken", # Token authentication
"drf_spectacular", # OpenAPI 3.0 documentation
"corsheaders", # CORS headers for API
"pghistory", # django-pghistory
@@ -94,6 +95,7 @@ LOCAL_APPS = [
"apps.accounts",
"apps.parks",
"apps.rides",
"apps.api", # New consolidated API app
"apps.email_service",
"apps.media.apps.MediaConfig",
"apps.moderation",
@@ -295,8 +297,11 @@ SPECTACULAR_SETTINGS = {
"SERVE_INCLUDE_SCHEMA": False,
"COMPONENT_SPLIT_REQUEST": True,
"TAGS": [
{"name": "parks", "description": "Theme park operations"},
{"name": "rides", "description": "Ride information and management"},
{"name": "Parks", "description": "Theme park operations"},
{"name": "Rides", "description": "Ride information and management"},
{"name": "Statistics",
"description": "Statistical endpoints providing aggregated data and insights"},
{"name": "Reviews", "description": "User reviews and ratings for parks and rides"},
{"name": "locations", "description": "Geographic location services"},
{"name": "accounts", "description": "User account management"},
{"name": "media", "description": "Media and image management"},
@@ -304,6 +309,13 @@ SPECTACULAR_SETTINGS = {
],
"SCHEMA_PATH_PREFIX": "/api/",
"DEFAULT_GENERATOR_CLASS": "drf_spectacular.generators.SchemaGenerator",
"DEFAULT_AUTO_SCHEMA": "apps.api.v1.schema.ThrillWikiAutoSchema",
"PREPROCESSING_HOOKS": [
"apps.api.v1.schema.custom_preprocessing_hook",
],
"POSTPROCESSING_HOOKS": [
"apps.api.v1.schema.custom_postprocessing_hook",
],
"SERVE_PERMISSIONS": ["rest_framework.permissions.AllowAny"],
"SWAGGER_UI_SETTINGS": {
"deepLinking": True,

2988
backend/schema.yml Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
#!/usr/bin/env python
"""
Script to set up social authentication providers for development.
Run this with: python manage.py shell < setup_social_providers.py
"""
from allauth.socialaccount.models import SocialApp
from django.contrib.sites.models import Site
# Get the current site
site = Site.objects.get_current()
print(f"Setting up social providers for site: {site}")
# Clear existing social apps to avoid duplicates
SocialApp.objects.all().delete()
print("Cleared existing social apps")
# Create Google social app
google_app = SocialApp.objects.create(
provider='google',
name='Google',
client_id='demo-google-client-id.apps.googleusercontent.com',
secret='demo-google-client-secret',
key='', # Not used for Google
)
google_app.sites.add(site)
print("✅ Created Google social app")
# Create Discord social app
discord_app = SocialApp.objects.create(
provider='discord',
name='Discord',
client_id='demo-discord-client-id',
secret='demo-discord-client-secret',
key='', # Not used for Discord
)
discord_app.sites.add(site)
print("✅ Created Discord social app")
# List all social apps
print("\nConfigured social apps:")
for app in SocialApp.objects.all():
print(f"- {app.name} ({app.provider}): {app.client_id}")
print(f"\nTotal social apps: {SocialApp.objects.count()}")

View File

@@ -35,6 +35,8 @@ INSTALLED_APPS = [
"django.contrib.staticfiles",
"django.contrib.sites",
"django.contrib.gis", # Add GeoDjango
"rest_framework", # Django REST Framework
"rest_framework.authtoken", # Token authentication
"pghistory", # Add django-pghistory
"pgtrigger", # Required by django-pghistory
"allauth",

View File

@@ -21,17 +21,8 @@ try:
except ImportError:
HAS_SPECTACULAR = False
# Import enhanced health check views
try:
from apps.core.views.health_views import (
HealthCheckAPIView,
PerformanceMetricsView,
SimpleHealthView,
)
HAS_HEALTH_VIEWS = True
except ImportError:
HAS_HEALTH_VIEWS = False
# Health check views are now consolidated in the API v1
HAS_HEALTH_VIEWS = False
# Import autocomplete URLs
try:
@@ -48,9 +39,9 @@ urlpatterns = [
path("", HomeView.as_view(), name="home"),
# Health Check URLs
path("health/", include("health_check.urls")),
# API URLs (before app URLs to avoid conflicts)
path("api/v1/", include("apps.parks.api.urls", namespace="parks_api")),
path("api/v1/", include("apps.rides.api.urls", namespace="rides_api")),
# New Consolidated API v1 URLs
path("api/v1/", include("apps.api.v1.urls", namespace="api_v1")),
# All API endpoints are now consolidated under /api/v1/
path(
"api/v1/map/", include("apps.core.urls.map_urls", namespace="map_api")
), # Map API URLs
@@ -139,23 +130,7 @@ if HAS_SPECTACULAR:
]
)
# Add enhanced health check URLs if available
if HAS_HEALTH_VIEWS:
urlpatterns.extend(
[
path("health/api/", HealthCheckAPIView.as_view(), name="health-api"),
path(
"health/simple/",
SimpleHealthView.as_view(),
name="health-simple",
),
path(
"health/metrics/",
PerformanceMetricsView.as_view(),
name="health-metrics",
),
]
)
# Health check API endpoints are now available at /api/v1/health/
# Serve static files in development
if settings.DEBUG:

Binary file not shown.

Binary file not shown.

8
frontend/.editorconfig Normal file
View File

@@ -0,0 +1,8 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

View File

@@ -0,0 +1,6 @@
# Development environment configuration
VITE_API_BASE_URL=
VITE_APP_ENV=development
VITE_APP_NAME=ThrillWiki
VITE_APP_VERSION=1.0.0
VITE_DEBUG=true

6
frontend/.env.production Normal file
View File

@@ -0,0 +1,6 @@
# Production environment configuration
VITE_API_BASE_URL=https://api.thrillwiki.com
VITE_APP_ENV=production
VITE_APP_NAME=ThrillWiki
VITE_APP_VERSION=1.0.0
VITE_DEBUG=false

6
frontend/.env.staging Normal file
View File

@@ -0,0 +1,6 @@
# Staging environment configuration
VITE_API_BASE_URL=https://staging-api.thrillwiki.com
VITE_APP_ENV=staging
VITE_APP_NAME=ThrillWiki (Staging)
VITE_APP_VERSION=1.0.0
VITE_DEBUG=true

1
frontend/.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

33
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,33 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
test-results/
playwright-report/

View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

View File

@@ -1,49 +1,73 @@
# ThrillWiki Frontend
Vue.js SPA frontend for the ThrillWiki monorepo.
Modern Vue.js 3 SPA frontend for the ThrillWiki theme park and roller coaster information system.
## 🏗️ Architecture
## 🏗️ Architecture Overview
Modern Vue 3 application with TypeScript and composition API:
This frontend is built with Vue 3 and follows modern development practices:
```
frontend/
├── src/
│ ├── components/ # Vue components
│ │ ├── common/ # Shared components
│ │ ├── parks/ # Park-specific components
│ │ ├── rides/ # Ride-specific components
│ │ ── moderation/ # Moderation components
│ ├── components/ # Reusable UI components
│ │ ├── ui/ # Base UI components (shadcn-vue style)
│ │ ├── layout/ # Layout components (Navbar, ThemeController)
│ │ ├── button/ # Button variants
│ │ ── icon/ # Icon components
│ │ └── state-layer/ # Material Design state layers
│ ├── views/ # Page components
│ ├── router/ # Vue Router setup
│ ├── Home.vue # Landing page
│ │ ├── SearchResults.vue # Search results page
│ │ ├── parks/ # Park-related pages
│ │ └── rides/ # Ride-related pages
│ ├── stores/ # Pinia state management
│ ├── composables/ # Vue 3 composables
│ ├── services/ # API services
│ ├── utils/ # Utility functions
│ ├── types/ # TypeScript types
│ └── assets/ # Static assets
├── public/ # Public assets
── tests/ # Frontend tests
│ ├── router/ # Vue Router configuration
│ ├── services/ # API services and utilities
│ ├── types/ # TypeScript type definitions
│ ├── App.vue # Root component
│ └── main.ts # Application entry point
├── public/ # Static assets
── dist/ # Production build output
└── e2e/ # End-to-end tests
```
## 🛠️ Technology Stack
## 🚀 Technology Stack
- **Vue 3** - Frontend framework with Composition API
- **TypeScript** - Type safety and better developer experience
- **Vite** - Fast build tool and dev server
- **Vue Router** - Client-side routing
- **Pinia** - State management
- **Tailwind CSS** - Utility-first CSS framework
- **Headless UI** - Unstyled, accessible UI components
- **Heroicons** - Beautiful SVG icons
- **Axios** - HTTP client for API requests
### Core Framework
- **Vue 3** with Composition API and `<script setup>` syntax
- **TypeScript** for type safety and better developer experience
- **Vite** for lightning-fast development and optimized production builds
## 🚀 Quick Start
### UI & Styling
- **Tailwind CSS v4** with custom design system
- **shadcn-vue** inspired component library
- **Material Design** state layers and interactions
- **Dark mode support** with automatic theme detection
### State Management & Routing
- **Pinia** for predictable state management
- **Vue Router 4** for client-side routing
### Development & Testing
- **Vitest** for fast unit testing
- **Playwright** for end-to-end testing
- **ESLint** with Vue and TypeScript rules
- **Prettier** for code formatting
- **Vue DevTools** integration
### Build & Performance
- **Vite** with optimized build pipeline
- **Vue 3's reactivity system** for optimal performance
- **Tree-shaking** and code splitting
- **PWA capabilities** for mobile experience
## 🛠️ Development Workflow
### Prerequisites
- Node.js 18+
- pnpm 8+
- **Node.js 20+** (see `engines` in package.json)
- **pnpm** package manager
- **Backend API** running on `http://localhost:8000`
### Setup
@@ -55,292 +79,306 @@ frontend/
2. **Environment configuration**
```bash
cp .env.example .env
# Edit .env with your settings
cp .env.development .env.local
# Edit .env.local with your settings
```
3. **Start development server**
```bash
pnpm run dev
pnpm dev
```
The application will be available at `http://localhost:5174`
Frontend will be available at http://localhost:3000
### Available Scripts
```bash
# Development
pnpm dev # Start dev server with hot reload
pnpm preview # Preview production build locally
# Building
pnpm build # Build for production
pnpm build-only # Build without type checking
pnpm type-check # TypeScript type checking only
# Testing
pnpm test:unit # Run unit tests with Vitest
pnpm test:e2e # Run E2E tests with Playwright
# Code Quality
pnpm lint # Run ESLint with auto-fix
pnpm lint:eslint # ESLint only
pnpm lint:oxlint # Oxlint (fast linter) only
pnpm format # Format code with Prettier
# Component Development
pnpm add # Add new components with Liftkit
```
## 🔧 Configuration
### Environment Variables
Create `.env.local` for local development:
```bash
# API Configuration
VITE_API_BASE_URL=http://localhost:8000/api
# App Configuration
VITE_APP_TITLE=ThrillWiki
VITE_APP_DESCRIPTION=Your ultimate guide to theme parks
# Application Settings
VITE_APP_TITLE=ThrillWiki (Development)
VITE_APP_VERSION=1.0.0
# Feature Flags
VITE_ENABLE_PWA=true
VITE_ENABLE_DEBUG=true
VITE_ENABLE_ANALYTICS=false
# Theme
VITE_DEFAULT_THEME=system
```
### Build Configuration
### Vite Configuration
- **Vite** configuration in `vite.config.ts`
- **TypeScript** configuration in `tsconfig.json`
- **Tailwind CSS** configuration in `tailwind.config.js`
The build system is configured in `vite.config.ts` with:
## 🧩 Components
### Component Structure
```typescript
// Example component structure
<template>
<div class="component-wrapper">
<!-- Template content -->
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { ComponentProps } from '@/types'
// Component logic
</script>
<style scoped>
/* Component-specific styles */
</style>
```
### Shared Components
- **AppHeader** - Navigation and user menu
- **AppSidebar** - Main navigation sidebar
- **LoadingSpinner** - Loading indicator
- **ErrorMessage** - Error display component
- **SearchBox** - Global search functionality
## 🗂️ State Management
Using **Pinia** for state management:
```typescript
// stores/auth.ts
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const isAuthenticated = computed(() => !!user.value)
const login = async (credentials: LoginData) => {
// Login logic
}
return { user, isAuthenticated, login }
})
```
### Available Stores
- **authStore** - User authentication
- **parksStore** - Park data and operations
- **ridesStore** - Ride information
- **uiStore** - UI state (modals, notifications)
- **themeStore** - Dark/light mode toggle
## 🛣️ Routing
Vue Router setup with:
- **Route guards** for authentication
- **Lazy loading** for code splitting
- **Meta fields** for page titles and permissions
```typescript
// router/index.ts
const routes = [
{
path: '/',
name: 'Home',
component: () => import('@/views/Home.vue')
},
{
path: '/parks',
name: 'Parks',
component: () => import('@/views/parks/ParksIndex.vue')
}
]
```
## 🎨 Styling
- **Vue 3** plugin with JSX support
- **Path aliases** for clean imports
- **CSS preprocessing** with PostCSS and Tailwind
- **Development server** with proxy to backend API
- **Build optimizations** for production
### Tailwind CSS
- **Dark mode** support with class strategy
- **Custom colors** for brand consistency
- **Responsive design** utilities
- **Component classes** for reusable styles
Custom design system configured in `tailwind.config.js`:
### Theme System
- **Custom color palette** with CSS variables
- **Dark mode support** with `class` strategy
- **Component classes** for consistent styling
- **Material Design** inspired design tokens
```typescript
// composables/useTheme.ts
export const useTheme = () => {
const isDark = ref(false)
## 📁 Project Structure Details
const toggleTheme = () => {
isDark.value = !isDark.value
document.documentElement.classList.toggle('dark')
}
### Components Architecture
return { isDark, toggleTheme }
}
```
#### UI Components (`src/components/ui/`)
Base component library following shadcn-vue patterns:
## 🔌 API Integration
- **Button** - Multiple variants and sizes
- **Card** - Flexible content containers
- **Badge** - Status indicators and labels
- **SearchInput** - Search functionality with debouncing
- **Input, Textarea, Select** - Form components
- **Dialog, Sheet, Dropdown** - Overlay components
### Service Layer
#### Layout Components (`src/components/layout/`)
Application layout and navigation:
```typescript
// services/api.ts
class ApiService {
private client = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
withCredentials: true,
})
- **Navbar** - Main navigation with responsive design
- **ThemeController** - Dark/light mode toggle
- **Footer** - Site footer with links
// API methods
getParks(params?: ParkFilters) {
return this.client.get('/parks/', { params })
}
}
```
#### Specialized Components
- **State Layer** - Material Design ripple effects
- **Icon** - Lucide React icon wrapper
- **Button variants** - Different button styles
### Error Handling
### Views Structure
- Global error interceptors
- User-friendly error messages
- Retry mechanisms for failed requests
- Offline support indicators
#### Page Components (`src/views/`)
- **Home.vue** - Landing page with featured content
- **SearchResults.vue** - Global search results display
- **parks/ParkList.vue** - List of all parks
- **parks/ParkDetail.vue** - Individual park information
- **rides/RideList.vue** - List of rides with filtering
- **rides/RideDetail.vue** - Detailed ride information
## 🧪 Testing
### State Management
### Test Structure
#### Pinia Stores (`src/stores/`)
- **Theme Store** - Dark/light mode state
- **Search Store** - Search functionality and results
- **Park Store** - Park data management
- **Ride Store** - Ride data management
- **UI Store** - General UI state
```bash
tests/
├── unit/ # Unit tests
├── e2e/ # End-to-end tests
└── __mocks__/ # Mock files
```
### API Integration
### Running Tests
#### Services (`src/services/`)
- **API client** with Axios configuration
- **Authentication** service
- **Park service** - CRUD operations for parks
- **Ride service** - CRUD operations for rides
- **Search service** - Global search functionality
```bash
# Unit tests
pnpm run test
### Type Definitions
# E2E tests
pnpm run test:e2e
#### TypeScript Types (`src/types/`)
- **API response types** matching backend serializers
- **Component prop types** for better type safety
- **Store state types** for Pinia stores
- **Utility types** for common patterns
# Watch mode
pnpm run test:watch
```
## 🎨 Design System
### Testing Tools
### Color Palette
- **Primary colors** - Brand identity
- **Semantic colors** - Success, warning, error states
- **Neutral colors** - Grays for text and backgrounds
- **Dark mode variants** - Automatic color adjustments
- **Vitest** - Unit testing framework
- **Vue Test Utils** - Vue component testing
- **Playwright** - End-to-end testing
### Typography
- **Inter font family** for modern appearance
- **Responsive text scales** for all screen sizes
- **Consistent line heights** for readability
## 📱 Progressive Web App
### Component Variants
- **Button variants** - Primary, secondary, outline, ghost
- **Card variants** - Default, elevated, outlined
- **Input variants** - Default, error, success
PWA features:
### Dark Mode
- **Automatic detection** of system preference
- **Manual toggle** in theme controller
- **Smooth transitions** between themes
- **CSS custom properties** for dynamic theming
- **Service Worker** for offline functionality
- **App Manifest** for installation
- **Push Notifications** for updates
- **Background Sync** for data synchronization
## 🧪 Testing Strategy
## 🔧 Build & Deployment
### Unit Tests (Vitest)
- **Component testing** with Vue Test Utils
- **Composable testing** for custom hooks
- **Service testing** for API calls
- **Store testing** for Pinia state management
### Development Build
### End-to-End Tests (Playwright)
- **User journey testing** - Complete user flows
- **Cross-browser testing** - Chrome, Firefox, Safari
- **Mobile testing** - Responsive behavior
- **Accessibility testing** - WCAG compliance
```bash
pnpm run dev
```
### Production Build
```bash
pnpm run build
```
### Preview Production Build
```bash
pnpm run preview
```
### Build Output
- Static assets in `dist/`
- Optimized and minified code
- Source maps for debugging
- Chunk splitting for performance
## 🎯 Performance
### Optimization Strategies
- **Code splitting** with dynamic imports
- **Image optimization** with responsive loading
- **Bundle analysis** with rollup-plugin-visualizer
- **Lazy loading** for routes and components
### Core Web Vitals
- First Contentful Paint (FCP)
- Largest Contentful Paint (LCP)
- Cumulative Layout Shift (CLS)
## ♿ Accessibility
- **ARIA labels** and roles
- **Keyboard navigation** support
- **Screen reader** compatibility
- **Color contrast** compliance
- **Focus management**
## 🐛 Debugging
### Development Tools
- Vue DevTools browser extension
- Vite's built-in debugging features
- TypeScript error reporting
- Hot module replacement (HMR)
### Logging
```typescript
// utils/logger.ts
export const logger = {
info: (message: string, data?: any) => {
if (import.meta.env.DEV) {
console.log(message, data)
}
}
}
```
### Test Configuration
- **Vitest config** in `vitest.config.ts`
- **Playwright config** in `playwright.config.ts`
- **Test utilities** in `src/__tests__/`
- **Mock data** for consistent testing
## 🚀 Deployment
See the [Deployment Guide](../shared/docs/deployment/) for production setup.
### Build Process
```bash
# Production build
pnpm build
# Preview build locally
pnpm preview
# Type checking before build
pnpm type-check
```
### Build Output
- **Optimized bundles** with code splitting
- **Asset optimization** (images, fonts, CSS)
- **Source maps** for debugging (development only)
- **Service worker** for PWA features
### Environment Configurations
- **Development** - `.env.development`
- **Staging** - `.env.staging`
- **Production** - `.env.production`
## 🔧 Development Tools
### IDE Setup
- **VSCode** with Volar extension
- **Vue Language Features** for better Vue support
- **TypeScript Importer** for auto-imports
- **Tailwind CSS IntelliSense** for styling
### Browser Extensions
- **Vue DevTools** for debugging
- **Tailwind CSS DevTools** for styling
- **Playwright Inspector** for E2E testing
### Performance Monitoring
- **Vite's built-in analyzer** for bundle analysis
- **Vue DevTools performance tab**
- **Lighthouse** for performance metrics
## 📖 API Integration
### Backend Communication
- **RESTful API** integration with Django backend
- **Automatic field conversion** (snake_case ↔ camelCase)
- **Error handling** with user-friendly messages
- **Loading states** for better UX
### Authentication Flow
- **JWT token management**
- **Automatic token refresh**
- **Protected routes** with guards
- **User session management**
## 🤝 Contributing
1. Follow Vue.js style guide
2. Use TypeScript for type safety
3. Write tests for components
4. Follow Prettier formatting
5. Use conventional commits
### Code Standards
1. **Vue 3 Composition API** with `<script setup>` syntax
2. **TypeScript** for all new components and utilities
3. **Component naming** following Vue.js conventions
4. **CSS classes** using Tailwind utility classes
### Development Process
1. **Create feature branch** from `main`
2. **Follow component structure** guidelines
3. **Add tests** for new functionality
4. **Update documentation** as needed
5. **Submit pull request** with description
### Component Creation
```bash
# Add new component with Liftkit
pnpm add
# Follow the prompts to create component structure
```
## 🐛 Troubleshooting
### Common Issues
#### Build Errors
- **TypeScript errors** - Run `pnpm type-check` to identify issues
- **Missing dependencies** - Run `pnpm install` to sync packages
- **Vite configuration** - Check `vite.config.ts` for build settings
#### Runtime Errors
- **API connection** - Verify backend is running on port 8000
- **Environment variables** - Check `.env.local` configuration
- **CORS issues** - Configure backend CORS settings
#### Development Issues
- **Hot reload not working** - Restart dev server
- **Type errors** - Check TypeScript configuration
- **Styling issues** - Verify Tailwind classes
### Performance Tips
- **Use Composition API** for better performance
- **Lazy load components** for better initial load
- **Optimize images** and assets
- **Use `computed` properties** for derived state
## 📄 License
This project is licensed under the MIT License - see the [LICENSE](../LICENSE) file for details.
## 🙏 Acknowledgments
- **Vue.js Team** for the excellent framework
- **Vite Team** for the blazing fast build tool
- **Tailwind CSS** for the utility-first approach
- **shadcn-vue** for component inspiration
- **ThrillWiki Community** for feedback and support
---
**Built with ❤️ for the theme park and roller coaster community**

13
frontend/components.json Normal file
View File

@@ -0,0 +1,13 @@
{
"style": "new-york",
"rsc": true,
"tsx": true,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@@ -0,0 +1,4 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"include": ["./**/*"]
}

8
frontend/e2e/vue.spec.ts Normal file
View File

@@ -0,0 +1,8 @@
import { test, expect } from '@playwright/test';
// See here how to get started:
// https://playwright.dev/docs/intro
test('visits the app root url', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1')).toHaveText('You did it!');
})

1
frontend/env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

36
frontend/eslint.config.ts Normal file
View File

@@ -0,0 +1,36 @@
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import pluginVitest from '@vitest/eslint-plugin'
import pluginPlaywright from 'eslint-plugin-playwright'
import pluginOxlint from 'eslint-plugin-oxlint'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{ts,mts,tsx,vue}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
{
...pluginVitest.configs.recommended,
files: ['src/**/__tests__/*'],
},
{
...pluginPlaywright.configs['flat/recommended'],
files: ['e2e/**/*.{test,spec}.{js,ts,jsx,tsx}'],
},
...pluginOxlint.configs['flat/recommended'],
skipFormatting,
)

View File

@@ -1,11 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<html lang="">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ThrillWiki</title>
<meta name="description" content="Your ultimate guide to theme parks and thrilling rides" />
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>

View File

@@ -1,47 +1,66 @@
{
"name": "thrillwiki-frontend",
"version": "0.1.0",
"description": "ThrillWiki Vue.js Frontend",
"name": "frontend",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"test": "vitest",
"test:unit": "vitest",
"test:e2e": "playwright test",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint:oxlint": "oxlint . --fix -D correctness --ignore-path .gitignore",
"lint:eslint": "eslint . --fix",
"lint": "run-s lint:*",
"format": "prettier --write src/",
"type-check": "vue-tsc --noEmit"
"add": "liftkit add"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"pinia": "^2.1.0",
"axios": "^1.6.0",
"@headlessui/vue": "^1.7.0",
"@heroicons/vue": "^2.0.0"
"@csstools/normalize.css": "^12.1.1",
"@heroicons/vue": "^2.2.0",
"@material/material-color-utilities": "^0.3.0",
"lucide-react": "^0.541.0",
"pinia": "^3.0.3",
"vue": "^3.5.19",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.0.0",
"vue-tsc": "^2.0.0",
"typescript": "^5.3.0",
"tailwindcss": "^3.4.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"eslint": "^8.57.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"eslint-plugin-vue": "^9.20.0",
"prettier": "^3.2.0",
"vitest": "^1.3.0",
"@playwright/test": "^1.42.0",
"@vue/test-utils": "^2.4.0",
"jsdom": "^24.0.0"
},
"engines": {
"node": ">=18.0.0",
"pnpm": ">=8.0.0"
"@chainlift/liftkit": "^0.2.0",
"@playwright/test": "^1.55.0",
"@prettier/plugin-oxc": "^0.0.4",
"@tailwindcss/postcss": "^4.1.12",
"@tailwindcss/vite": "^4.1.12",
"@tsconfig/node22": "^22.0.2",
"@types/jsdom": "^21.1.7",
"@types/node": "^24.3.0",
"@vitejs/plugin-vue": "^6.0.1",
"@vitejs/plugin-vue-jsx": "^5.0.1",
"@vitest/eslint-plugin": "^1.3.4",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
"autoprefixer": "^10.4.21",
"eslint": "^9.34.0",
"eslint-plugin-oxlint": "~1.12.0",
"eslint-plugin-playwright": "^2.2.2",
"eslint-plugin-vue": "~10.4.0",
"jiti": "^2.5.1",
"jsdom": "^26.1.0",
"npm-run-all2": "^8.0.4",
"oxlint": "~1.12.0",
"postcss": "^8.5.6",
"prettier": "3.6.2",
"tailwindcss": "^4.1.12",
"typescript": "~5.9.2",
"vite": "npm:rolldown-vite@^7.1.4",
"vite-plugin-vue-devtools": "^8.0.1",
"vitest": "^3.2.4",
"vue-tsc": "^3.0.6"
}
}

View File

@@ -0,0 +1,110 @@
import process from 'node:process'
import { defineConfig, devices } from '@playwright/test'
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './e2e',
/* Maximum time one test can run for. */
timeout: 30 * 1000,
expect: {
/**
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: 5000,
},
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
actionTimeout: 0,
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: process.env.CI ? 'http://localhost:4173' : 'http://localhost:5173',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
/* Only on CI systems run the tests headless */
headless: !!process.env.CI,
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
},
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
},
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
},
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: {
// ...devices['Pixel 5'],
// },
// },
// {
// name: 'Mobile Safari',
// use: {
// ...devices['iPhone 12'],
// },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: {
// channel: 'msedge',
// },
// },
// {
// name: 'Google Chrome',
// use: {
// channel: 'chrome',
// },
// },
],
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
// outputDir: 'test-results/',
/* Run your local dev server before starting the tests */
webServer: {
/**
* Use the dev server by default for faster feedback loop.
* Use the preview server on CI for more realistic testing.
* Playwright will re-use the local server if there is already a dev-server running.
*/
command: process.env.CI ? 'npm run preview' : 'npm run dev',
port: process.env.CI ? 4173 : 5173,
reuseExistingServer: !process.env.CI,
},
})

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 4.2 KiB

459
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,459 @@
<template>
<div id="app" class="min-h-screen bg-gray-50 dark:bg-gray-900">
<!-- Authentication Modals -->
<AuthManager
:show="showAuthModal"
:initial-mode="authModalMode"
@close="closeAuthModal"
@success="handleAuthSuccess"
/>
<!-- Header Navigation -->
<header
class="sticky top-0 z-50 bg-white border-b border-gray-200 dark:bg-gray-800 dark:border-gray-700"
>
<nav class="container mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<!-- Logo -->
<div class="flex-shrink-0">
<router-link to="/" class="flex items-center space-x-2">
<div
class="w-8 h-8 bg-gradient-to-br from-blue-600 to-purple-600 rounded-lg flex items-center justify-center"
>
<span class="text-white font-bold text-sm">TW</span>
</div>
<h1 class="text-xl font-bold text-gray-900 dark:text-white">ThrillWiki</h1>
</router-link>
</div>
<!-- Browse Dropdown & Search -->
<div class="flex items-center space-x-4 flex-1 max-w-2xl mx-6">
<!-- Browse Dropdown -->
<div class="relative" v-if="!isMobile">
<button
@click="browseDropdownOpen = !browseDropdownOpen"
class="flex items-center space-x-1 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"
>
<span>Browse</span>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
<!-- Dropdown Menu -->
<div
v-show="browseDropdownOpen"
class="absolute left-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-50"
>
<div class="py-1">
<router-link
to="/parks/"
class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
@click="browseDropdownOpen = false"
>
All Parks
</router-link>
<router-link
to="/rides/"
class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
@click="browseDropdownOpen = false"
>
All Rides
</router-link>
<router-link
to="/search/parks/"
class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
@click="browseDropdownOpen = false"
>
Search Parks
</router-link>
<router-link
to="/search/rides/"
class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
@click="browseDropdownOpen = false"
>
Search Rides
</router-link>
</div>
</div>
</div>
<!-- Search Bar -->
<div class="flex-1 max-w-lg">
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg
class="h-5 w-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<input
type="search"
placeholder="Search parks, rides..."
class="block w-full pl-10 pr-16 py-2 border border-gray-300 rounded-md leading-5 bg-white dark:bg-gray-700 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 text-sm"
v-model="searchQuery"
@keyup.enter="handleSearch"
/>
<button
@click="handleSearch"
class="absolute inset-y-0 right-0 px-4 bg-gray-900 hover:bg-gray-800 dark:bg-gray-600 dark:hover:bg-gray-500 rounded-r-md text-white text-sm font-medium transition-colors"
>
Search
</button>
</div>
</div>
</div>
<!-- Right Side Actions -->
<div class="flex items-center space-x-3">
<!-- Theme Toggle -->
<button
@click="toggleTheme"
class="p-2 rounded-md text-gray-400 hover:text-gray-500 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
>
<svg
v-if="isDark"
class="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<svg v-else class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
</button>
<!-- Sign In / Sign Up -->
<div class="hidden md:flex items-center space-x-2">
<button
@click="showLoginModal"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"
>
Sign In
</button>
<button
@click="showSignupModal"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md transition-colors"
>
Sign Up
</button>
</div>
<!-- Mobile menu button -->
<button
@click="mobileMenuOpen = !mobileMenuOpen"
class="md:hidden p-2 rounded-md text-gray-400 hover:text-gray-500 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
v-if="!mobileMenuOpen"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
<path
v-else
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<!-- Mobile Navigation Menu -->
<div v-show="mobileMenuOpen" class="md:hidden">
<div
class="px-2 pt-2 pb-3 space-y-1 sm:px-3 border-t border-gray-200 dark:border-gray-700"
>
<router-link
to="/parks/"
class="text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400 block px-3 py-2 rounded-md text-base font-medium transition-colors"
@click="mobileMenuOpen = false"
>
Parks
</router-link>
<router-link
to="/rides/"
class="text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400 block px-3 py-2 rounded-md text-base font-medium transition-colors"
@click="mobileMenuOpen = false"
>
Rides
</router-link>
<div class="border-t border-gray-200 dark:border-gray-700 mt-3 pt-3">
<button
@click="showLoginModal"
class="w-full text-left px-3 py-2 text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400 font-medium transition-colors"
>
Sign In
</button>
<button
@click="showSignupModal"
class="w-full text-left px-3 py-2 text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400 font-medium transition-colors"
>
Sign Up
</button>
</div>
</div>
</div>
</nav>
</header>
<!-- Main Content -->
<main class="flex-1">
<router-view />
</main>
<!-- Footer -->
<footer class="bg-white border-t border-gray-200 dark:bg-gray-800 dark:border-gray-700">
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<!-- Logo & Description -->
<div class="col-span-1">
<div class="flex items-center space-x-2 mb-4">
<div
class="w-8 h-8 bg-gradient-to-br from-blue-600 to-purple-600 rounded-lg flex items-center justify-center"
>
<span class="text-white font-bold text-sm">TW</span>
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-white">ThrillWiki</h3>
</div>
<p class="text-gray-600 dark:text-gray-400 text-sm max-w-xs">
The ultimate database for theme park rides and attractions worldwide.
</p>
</div>
<!-- Explore -->
<div>
<h4 class="font-semibold text-gray-900 dark:text-white mb-4">Explore</h4>
<ul class="space-y-2">
<li>
<router-link
to="/parks/"
class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white text-sm transition-colors"
>Parks</router-link
>
</li>
<li>
<router-link
to="/rides/"
class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white text-sm transition-colors"
>Rides</router-link
>
</li>
<li>
<a
href="#"
class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white text-sm transition-colors"
>Manufacturers</a
>
</li>
<li>
<a
href="#"
class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white text-sm transition-colors"
>Operators</a
>
</li>
<li>
<a
href="#"
class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white text-sm transition-colors"
>Top Lists</a
>
</li>
</ul>
</div>
<!-- Community -->
<div>
<h4 class="font-semibold text-gray-900 dark:text-white mb-4">Community</h4>
<ul class="space-y-2">
<li>
<a
href="#"
class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white text-sm transition-colors"
>Join ThrillWiki</a
>
</li>
<li>
<a
href="#"
class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white text-sm transition-colors"
>Contribute</a
>
</li>
<li>
<a
href="#"
class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white text-sm transition-colors"
>Guidelines</a
>
</li>
</ul>
</div>
<!-- Legal -->
<div>
<h4 class="font-semibold text-gray-900 dark:text-white mb-4">Legal</h4>
<ul class="space-y-2">
<li>
<a
href="#"
class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white text-sm transition-colors"
>Terms of Service</a
>
</li>
<li>
<a
href="#"
class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white text-sm transition-colors"
>Privacy Policy</a
>
</li>
<li>
<a
href="#"
class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white text-sm transition-colors"
>Contact</a
>
</li>
</ul>
</div>
</div>
<!-- Copyright -->
<div class="border-t border-gray-200 dark:border-gray-700 mt-8 pt-8">
<p class="text-center text-gray-600 dark:text-gray-400 text-sm">
© 2024 ThrillWiki. All rights reserved.
</p>
</div>
</div>
</footer>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import AuthManager from '@/components/auth/AuthManager.vue'
const router = useRouter()
const searchQuery = ref('')
const mobileMenuOpen = ref(false)
const browseDropdownOpen = ref(false)
const isDark = ref(false)
const isMobile = ref(false)
// Authentication modal state
const showAuthModal = ref(false)
const authModalMode = ref<'login' | 'signup'>('login')
// Mobile detection with proper lifecycle management
const updateMobileDetection = () => {
isMobile.value = window.innerWidth < 768
}
// Theme Management
const toggleTheme = () => {
isDark.value = !isDark.value
if (isDark.value) {
document.documentElement.classList.add('dark')
localStorage.setItem('theme', 'dark')
} else {
document.documentElement.classList.remove('dark')
localStorage.setItem('theme', 'light')
}
}
// Initialize theme and mobile detection
onMounted(() => {
// Initialize mobile detection
updateMobileDetection()
window.addEventListener('resize', updateMobileDetection)
// Initialize theme from localStorage
const savedTheme = localStorage.getItem('theme')
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
isDark.value = savedTheme === 'dark' || (!savedTheme && prefersDark)
if (isDark.value) {
document.documentElement.classList.add('dark')
}
})
// Cleanup event listeners
onUnmounted(() => {
window.removeEventListener('resize', updateMobileDetection)
})
// Search functionality
const handleSearch = () => {
if (searchQuery.value.trim()) {
router.push({
name: 'search-results',
query: { q: searchQuery.value.trim() },
})
}
}
// Authentication modal functions
const showLoginModal = () => {
authModalMode.value = 'login'
showAuthModal.value = true
mobileMenuOpen.value = false // Close mobile menu if open
}
const showSignupModal = () => {
authModalMode.value = 'signup'
showAuthModal.value = true
mobileMenuOpen.value = false // Close mobile menu if open
}
const closeAuthModal = () => {
showAuthModal.value = false
}
const handleAuthSuccess = () => {
// Handle successful authentication
// This could include redirecting to a dashboard, updating user state, etc.
console.log('Authentication successful!')
}
</script>
<style scoped>
@reference "./style.css";
/* Additional component-specific styles if needed */
.router-link-active {
@apply text-blue-600 dark:text-blue-400;
}
</style>

View File

@@ -0,0 +1,11 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import App from '../App.vue'
describe('App', () => {
it('mounts renders properly', () => {
const wrapper = mount(App)
expect(wrapper.text()).toContain('You did it!')
})
})

View File

@@ -1,23 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
font-family: 'Inter', system-ui, sans-serif;
}
}
@layer components {
.btn {
@apply px-4 py-2 rounded-md font-medium focus:outline-none focus:ring-2 focus:ring-offset-2;
}
.btn-primary {
@apply btn bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500;
}
.btn-secondary {
@apply btn bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500;
}
}

View File

@@ -0,0 +1,81 @@
<template>
<!-- Login Modal -->
<LoginModal
:show="showLogin"
@close="closeAllModals"
@showSignup="switchToSignup"
@success="handleAuthSuccess"
/>
<!-- Signup Modal -->
<SignupModal
:show="showSignup"
@close="closeAllModals"
@showLogin="switchToLogin"
@success="handleAuthSuccess"
/>
</template>
<script setup lang="ts">
import { ref, watch, defineProps, defineEmits } from 'vue'
import LoginModal from './LoginModal.vue'
import SignupModal from './SignupModal.vue'
interface Props {
show?: boolean
initialMode?: 'login' | 'signup'
}
const props = withDefaults(defineProps<Props>(), {
show: false,
initialMode: 'login',
})
const emit = defineEmits<{
close: []
success: []
}>()
// Initialize reactive state
const showLogin = ref(false)
const showSignup = ref(false)
// Define helper functions with explicit function declarations to avoid hoisting issues
function closeAllModals() {
showLogin.value = false
showSignup.value = false
emit('close')
}
function switchToLogin() {
showSignup.value = false
showLogin.value = true
}
function switchToSignup() {
showLogin.value = false
showSignup.value = true
}
function handleAuthSuccess() {
closeAllModals()
emit('success')
}
// Watch for prop changes to show the appropriate modal
watch(
() => props.show,
(shouldShow) => {
if (shouldShow) {
if (props.initialMode === 'signup') {
switchToSignup()
} else {
switchToLogin()
}
} else {
closeAllModals()
}
},
{ immediate: true },
)
</script>

View File

@@ -0,0 +1,103 @@
<template>
<Teleport to="body">
<Transition
enter-active-class="duration-300 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="show"
class="fixed inset-0 z-50 overflow-y-auto"
@click="closeOnBackdrop && handleBackdropClick"
>
<!-- Backdrop -->
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
<!-- Modal Container -->
<div class="flex min-h-full items-center justify-center p-4">
<Transition
enter-active-class="duration-300 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="duration-200 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="show"
class="relative w-full max-w-md transform overflow-hidden rounded-2xl bg-white dark:bg-gray-800 shadow-2xl transition-all"
@click.stop
>
<!-- Header -->
<div class="flex items-center justify-between p-6 pb-4">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ title }}
</h2>
<button
@click="$emit('close')"
class="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300 transition-colors"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Content -->
<div class="px-6 pb-6">
<slot />
</div>
</div>
</Transition>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { watch, toRefs, onUnmounted } from 'vue'
interface Props {
show: boolean
title: string
closeOnBackdrop?: boolean
}
const props = withDefaults(defineProps<Props>(), {
closeOnBackdrop: true,
})
const emit = defineEmits<{
close: []
}>()
const handleBackdropClick = (event: MouseEvent) => {
if (props.closeOnBackdrop && event.target === event.currentTarget) {
emit('close')
}
}
// Prevent body scroll when modal is open
const { show } = toRefs(props)
watch(show, (isShown) => {
if (isShown) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
})
// Clean up on unmount
onUnmounted(() => {
document.body.style.overflow = ''
})
</script>

View File

@@ -0,0 +1,175 @@
<template>
<AuthModal :show="show" title="Reset Password" @close="$emit('close')">
<div v-if="!emailSent">
<p class="text-gray-600 dark:text-gray-400 text-sm mb-6">
Enter your email address and we'll send you a link to reset your password.
</p>
<form @submit.prevent="handleSubmit" class="space-y-6">
<!-- Error Messages -->
<div
v-if="authError"
class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4"
>
<div class="text-sm text-red-600 dark:text-red-400">{{ authError }}</div>
</div>
<!-- Email Field -->
<div>
<label
for="reset-email"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Email Address
</label>
<input
id="reset-email"
v-model="email"
type="email"
required
autocomplete="email"
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
placeholder="Enter your email address"
/>
</div>
<!-- Submit Button -->
<div>
<button
type="submit"
:disabled="isLoading"
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg
v-if="isLoading"
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{ isLoading ? 'Sending...' : 'Send Reset Link' }}
</button>
</div>
</form>
<!-- Back to Login -->
<div class="mt-6 text-sm text-center">
<button
@click="$emit('close')"
class="font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300"
>
Back to Sign In
</button>
</div>
</div>
<!-- Success State -->
<div v-else class="text-center">
<div
class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 dark:bg-green-900/20 mb-4"
>
<svg
class="h-6 w-6 text-green-600 dark:text-green-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">Check your email</h3>
<p class="text-gray-600 dark:text-gray-400 text-sm mb-6">
We've sent a password reset link to <strong>{{ email }}</strong>
</p>
<div class="space-y-3">
<button
@click="handleResend"
:disabled="isLoading"
class="w-full flex justify-center py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-lg shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{{ isLoading ? 'Sending...' : 'Resend Email' }}
</button>
<button
@click="$emit('close')"
class="w-full py-2 px-4 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300"
>
Back to Sign In
</button>
</div>
</div>
</AuthModal>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import AuthModal from './AuthModal.vue'
import { useAuth } from '@/composables/useAuth'
interface Props {
show: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
close: []
}>()
const auth = useAuth()
const { isLoading, authError } = auth
const email = ref('')
const emailSent = ref(false)
const handleSubmit = async () => {
try {
await auth.requestPasswordReset(email.value)
emailSent.value = true
} catch (error) {
console.error('Password reset request failed:', error)
}
}
const handleResend = async () => {
try {
await auth.requestPasswordReset(email.value)
} catch (error) {
console.error('Resend failed:', error)
}
}
// Reset state when modal closes
watch(
() => props.show,
(isShown) => {
if (!isShown) {
email.value = ''
emailSent.value = false
auth.clearError()
}
},
)
</script>

View File

@@ -0,0 +1,237 @@
<template>
<AuthModal :show="show" title="Welcome Back" @close="$emit('close')">
<!-- Social Login Buttons -->
<div class="space-y-3 mb-6">
<button
v-for="provider in socialProviders"
:key="provider.id"
@click="loginWithProvider(provider)"
class="w-full flex items-center justify-center px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg shadow-sm bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
<component :is="getProviderIcon(provider.id)" class="w-5 h-5 mr-3" />
Continue with {{ provider.name }}
</button>
</div>
<!-- Divider -->
<div v-if="socialProviders.length > 0" class="relative mb-6">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300 dark:border-gray-600"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-4 bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400">
Or continue with email
</span>
</div>
</div>
<!-- Login Form -->
<form @submit.prevent="handleLogin" class="space-y-6">
<!-- Error Messages -->
<div
v-if="authError"
class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4"
>
<div class="text-sm text-red-600 dark:text-red-400">{{ authError }}</div>
</div>
<!-- Username/Email Field -->
<div>
<label
for="login-username"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Username or Email
</label>
<input
id="login-username"
v-model="form.username"
type="text"
required
autocomplete="username"
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
placeholder="Enter your username or email"
/>
</div>
<!-- Password Field -->
<div>
<label
for="login-password"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Password
</label>
<div class="relative">
<input
id="login-password"
v-model="form.password"
:type="showPassword ? 'text' : 'password'"
required
autocomplete="current-password"
class="w-full px-4 py-3 pr-12 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
placeholder="Enter your password"
/>
<button
type="button"
@click="showPassword = !showPassword"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<EyeIcon v-if="showPassword" class="h-5 w-5" />
<EyeSlashIcon v-else class="h-5 w-5" />
</button>
</div>
</div>
<!-- Remember Me & Forgot Password -->
<div class="flex items-center justify-between">
<div class="flex items-center">
<input
id="login-remember"
v-model="form.remember"
type="checkbox"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700"
/>
<label for="login-remember" class="ml-2 block text-sm text-gray-700 dark:text-gray-300">
Remember me
</label>
</div>
<button
type="button"
@click="showForgotPassword = true"
class="text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300"
>
Forgot password?
</button>
</div>
<!-- Submit Button -->
<div>
<button
type="submit"
:disabled="isLoading"
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg
v-if="isLoading"
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{ isLoading ? 'Signing in...' : 'Sign In' }}
</button>
</div>
</form>
<!-- Sign Up Link -->
<div class="mt-6 text-sm text-center">
<p class="text-gray-600 dark:text-gray-400">
Don't have an account?
<button
@click="$emit('showSignup')"
class="ml-1 font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300"
>
Sign up
</button>
</p>
</div>
<!-- Forgot Password Modal -->
<ForgotPasswordModal :show="showForgotPassword" @close="showForgotPassword = false" />
</AuthModal>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { EyeIcon, EyeSlashIcon } from '@heroicons/vue/24/outline'
import AuthModal from './AuthModal.vue'
import ForgotPasswordModal from './ForgotPasswordModal.vue'
import GoogleIcon from '../icons/GoogleIcon.vue'
import DiscordIcon from '../icons/DiscordIcon.vue'
import { useAuth } from '@/composables/useAuth'
import type { SocialAuthProvider } from '@/types'
interface Props {
show: boolean
}
defineProps<Props>()
const emit = defineEmits<{
close: []
showSignup: []
success: []
}>()
const auth = useAuth()
const { isLoading, authError } = auth
const showPassword = ref(false)
const showForgotPassword = ref(false)
const socialProviders = ref<SocialAuthProvider[]>([])
const form = ref({
username: '',
password: '',
remember: false,
})
// Load social providers on mount
onMounted(async () => {
socialProviders.value = await auth.getSocialProviders()
})
const handleLogin = async () => {
try {
await auth.login({
username: form.value.username,
password: form.value.password,
})
// Clear form
form.value = {
username: '',
password: '',
remember: false,
}
emit('success')
emit('close')
} catch (error) {
// Error is handled by the auth composable
console.error('Login failed:', error)
}
}
const loginWithProvider = (provider: SocialAuthProvider) => {
// Redirect to Django allauth provider URL
window.location.href = provider.authUrl
}
const getProviderIcon = (providerId: string) => {
switch (providerId) {
case 'google':
return GoogleIcon
case 'discord':
return DiscordIcon
default:
return 'div'
}
}
</script>

View File

@@ -0,0 +1,335 @@
<template>
<AuthModal :show="show" title="Create Account" @close="$emit('close')">
<!-- Social Login Buttons -->
<div class="space-y-3 mb-6">
<button
v-for="provider in socialProviders"
:key="provider.id"
@click="loginWithProvider(provider)"
class="w-full flex items-center justify-center px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg shadow-sm bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
<component :is="getProviderIcon(provider.id)" class="w-5 h-5 mr-3" />
Continue with {{ provider.name }}
</button>
</div>
<!-- Divider -->
<div v-if="socialProviders.length > 0" class="relative mb-6">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300 dark:border-gray-600"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-4 bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400">
Or create account with email
</span>
</div>
</div>
<!-- Signup Form -->
<form @submit.prevent="handleSignup" class="space-y-6">
<!-- Error Messages -->
<div
v-if="authError"
class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4"
>
<div class="text-sm text-red-600 dark:text-red-400">{{ authError }}</div>
</div>
<!-- Name Fields -->
<div class="grid grid-cols-2 gap-4">
<div>
<label
for="signup-first-name"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
First Name
</label>
<input
id="signup-first-name"
v-model="form.first_name"
type="text"
autocomplete="given-name"
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
placeholder="First name"
/>
</div>
<div>
<label
for="signup-last-name"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Last Name
</label>
<input
id="signup-last-name"
v-model="form.last_name"
type="text"
autocomplete="family-name"
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
placeholder="Last name"
/>
</div>
</div>
<!-- Username Field -->
<div>
<label
for="signup-username"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Username
</label>
<input
id="signup-username"
v-model="form.username"
type="text"
required
autocomplete="username"
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
placeholder="Choose a username"
/>
</div>
<!-- Email Field -->
<div>
<label
for="signup-email"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Email Address
</label>
<input
id="signup-email"
v-model="form.email"
type="email"
required
autocomplete="email"
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
placeholder="Enter your email"
/>
</div>
<!-- Password Field -->
<div>
<label
for="signup-password"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Password
</label>
<div class="relative">
<input
id="signup-password"
v-model="form.password"
:type="showPassword ? 'text' : 'password'"
required
autocomplete="new-password"
class="w-full px-4 py-3 pr-12 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
placeholder="Create a password"
/>
<button
type="button"
@click="showPassword = !showPassword"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<EyeIcon v-if="showPassword" class="h-5 w-5" />
<EyeSlashIcon v-else class="h-5 w-5" />
</button>
</div>
</div>
<!-- Confirm Password Field -->
<div>
<label
for="signup-password-confirm"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Confirm Password
</label>
<div class="relative">
<input
id="signup-password-confirm"
v-model="form.password_confirm"
:type="showPasswordConfirm ? 'text' : 'password'"
required
autocomplete="new-password"
class="w-full px-4 py-3 pr-12 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
placeholder="Confirm your password"
/>
<button
type="button"
@click="showPasswordConfirm = !showPasswordConfirm"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<EyeIcon v-if="showPasswordConfirm" class="h-5 w-5" />
<EyeSlashIcon v-else class="h-5 w-5" />
</button>
</div>
</div>
<!-- Terms and Privacy -->
<div class="flex items-start">
<input
id="signup-terms"
v-model="form.agreeToTerms"
type="checkbox"
required
class="h-4 w-4 mt-1 text-blue-600 focus:ring-blue-500 border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700"
/>
<label for="signup-terms" class="ml-2 block text-sm text-gray-700 dark:text-gray-300">
I agree to the
<a
href="/terms"
target="_blank"
class="text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300"
>
Terms of Service
</a>
and
<a
href="/privacy"
target="_blank"
class="text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300"
>
Privacy Policy
</a>
</label>
</div>
<!-- Submit Button -->
<div>
<button
type="submit"
:disabled="isLoading"
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg
v-if="isLoading"
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 714 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{ isLoading ? 'Creating Account...' : 'Create Account' }}
</button>
</div>
</form>
<!-- Sign In Link -->
<div class="mt-6 text-sm text-center">
<p class="text-gray-600 dark:text-gray-400">
Already have an account?
<button
@click="$emit('showLogin')"
class="ml-1 font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300"
>
Sign in
</button>
</p>
</div>
</AuthModal>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { EyeIcon, EyeSlashIcon } from '@heroicons/vue/24/outline'
import AuthModal from './AuthModal.vue'
import GoogleIcon from '../icons/GoogleIcon.vue'
import DiscordIcon from '../icons/DiscordIcon.vue'
import { useAuth } from '@/composables/useAuth'
import type { SocialAuthProvider } from '@/types'
interface Props {
show: boolean
}
defineProps<Props>()
const emit = defineEmits<{
close: []
showLogin: []
success: []
}>()
const auth = useAuth()
const { isLoading, authError } = auth
const showPassword = ref(false)
const showPasswordConfirm = ref(false)
const socialProviders = ref<SocialAuthProvider[]>([])
const form = ref({
first_name: '',
last_name: '',
username: '',
email: '',
password: '',
password_confirm: '',
agreeToTerms: false,
})
// Load social providers on mount
onMounted(async () => {
socialProviders.value = await auth.getSocialProviders()
})
const handleSignup = async () => {
try {
await auth.signup({
first_name: form.value.first_name,
last_name: form.value.last_name,
username: form.value.username,
email: form.value.email,
password: form.value.password,
password_confirm: form.value.password_confirm,
})
// Clear form
form.value = {
first_name: '',
last_name: '',
username: '',
email: '',
password: '',
password_confirm: '',
agreeToTerms: false,
}
emit('success')
emit('close')
} catch (error) {
// Error is handled by the auth composable
console.error('Signup failed:', error)
}
}
const loginWithProvider = (provider: SocialAuthProvider) => {
// Redirect to Django allauth provider URL
window.location.href = provider.login_url
}
const getProviderIcon = (providerId: string) => {
switch (providerId) {
case 'google':
return GoogleIcon
case 'discord':
return DiscordIcon
default:
return 'div'
}
}
</script>

View File

@@ -0,0 +1,87 @@
[data-lk-component='button'] {
/* DEFAULTS */
--button-font-size: var(--body-font-size);
--button-line-height: var(--lk-halfstep) !important;
--button-padX: var(--button-font-size);
--button-padY: calc(
var(--button-font-size) * calc(var(--lk-halfstep) / var(--lk-size-xl-unitless))
);
--button-padX-sideWithIcon: calc(var(--button-font-size) / var(--lk-wholestep));
--button-gap: calc(var(--button-padY) / var(--lk-eighthstep));
cursor: pointer;
display: inline-flex;
vertical-align: middle;
border: 1px solid rgba(0, 0, 0, 0);
border-radius: 100em;
position: relative;
text-decoration: none;
white-space: pre;
word-break: keep-all;
overflow: hidden;
padding: var(--button-padY) 1em;
font-weight: 500;
font-size: var(--button-font-size);
line-height: var(--button-line-height);
font-family: inherit;
}
/* SIZE VARIANTS */
[data-lk-button-size='sm'] {
--button-font-size: var(--subheading-font-size);
}
[data-lk-button-size='lg'] {
--button-font-size: var(--title3-font-size);
}
/* ICON-BASED PADDING ADJUSTMENTS */
[data-lk-component='button']:has([data-lk-icon-position='start']) {
padding-left: var(--button-padX-sideWithIcon);
padding-right: var(--button-padX);
}
[data-lk-component='button']:has([data-lk-icon-position='end']) {
padding-left: 1em;
padding-right: var(--button-padX-sideWithIcon);
}
[data-lk-component='button']:has([data-lk-icon-position='start']):has(
[data-lk-icon-position='end']
) {
padding-left: var(--button-padX-sideWithIcon);
padding-right: var(--button-padX-sideWithIcon);
}
/* CONTENT WRAPPER */
[data-lk-button-content-wrap='true'] {
display: inline-flex;
align-items: center;
gap: var(--button-gap);
}
/* TODO: Remove entirely */
/* [data-lk-component="button"] div:has(> [data-lk-component="icon"]) {
width: calc(1em * var(--lk-halfstep));
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
} */
/* ICON VERTICAL OPTICAL ALIGNMENTS */
[data-lk-button-optic-icon-shift='true'] div:has(> [data-lk-component='icon']) {
margin-top: calc(-1 * calc(1em * var(--lk-quarterstep-dec)));
}
/* STYLE VARIANTS */
[data-lk-button-variant='text'] {
background: transparent !important;
}
[data-lk-button-variant='outline'] {
background: transparent !important;
border: 1px solid var(--lk-outlinevariant);
}

View File

@@ -0,0 +1,137 @@
'use client'
import { useMemo } from 'react'
import { propsToDataAttrs } from '@/lib/utilities'
import { getOnToken } from '@/lib/colorUtils'
import { IconName } from 'lucide-react/dynamic'
import '@/components/button/button.css'
import StateLayer from '@/components/state-layer'
import { LkStateLayerProps } from '@/components/state-layer'
import Icon from '@/components/icon'
export interface LkButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
label?: string
variant?: 'fill' | 'outline' | 'text'
color?: LkColorWithOnToken
size?: 'sm' | 'md' | 'lg'
material?: string
startIcon?: IconName
endIcon?: IconName
opticIconShift?: boolean
modifiers?: string
stateLayerOverride?: LkStateLayerProps // Optional override for state layer properties
}
/**
* A customizable button component with support for various visual styles, sizes, and icons.
*
* @param props - The button component props
* @param props.label - The text content displayed inside the button. Defaults to "Button"
* @param props.variant - The visual style variant of the button. Defaults to "fill"
* @param props.color - The color theme of the button. Defaults to "primary"
* @param props.size - The size of the button (sm, md, lg). Defaults to "md"
* @param props.startIcon - Optional icon element to display at the start of the button
* @param props.endIcon - Optional icon element to display at the end of the button
* @param props.restProps - Additional props to be spread to the underlying button element
* @param props.opticIconShift - Boolean to control optical icon alignment on the y-axis. Defaults to true. Pulls icons up slightly.
* @param props.modifiers - Additional class names to concatenate onto the button's default class list
* @param props.stateLayerOverride - Optional override for state layer properties, allowing customization of the state layer's appearance
*
* @returns A styled button element with optional start/end icons and a state layer overlay
*
* @example
* ```tsx
* <Button
* label="Click me"
* variant="outline"
* color="secondary"
* size="lg"
* startIcon={<ChevronIcon />}
* />
* ```
*/
export default function Button({
label = 'Button',
variant = 'fill',
color = 'primary',
size = 'md',
startIcon,
endIcon,
opticIconShift = true,
modifiers,
stateLayerOverride,
...restProps
}: LkButtonProps) {
const lkButtonAttrs = useMemo(
() => propsToDataAttrs({ variant, color, size, startIcon, endIcon, opticIconShift }, 'button'),
[variant, color, size, startIcon, endIcon, opticIconShift],
)
const onColorToken = getOnToken(color) as LkColor
// Define different base color classes based on variant
let baseButtonClasses = ''
switch (variant) {
case 'fill':
baseButtonClasses = `bg-${color} color-${onColorToken}`
break
case 'outline':
case 'text':
baseButtonClasses = `color-${color}`
break
default:
baseButtonClasses = `bg-${color} color-${onColorToken}`
break
}
if (modifiers) {
baseButtonClasses += ` ${modifiers}`
}
/**Determine state layer props dynamically */
function getLocalStateLayerProps() {
if (stateLayerOverride) {
return stateLayerOverride
} else {
return {
bgColor: variant === 'fill' ? onColorToken : color,
}
}
}
const localStateLayerProps: LkStateLayerProps = getLocalStateLayerProps()
return (
<button
{...lkButtonAttrs}
{...restProps}
type="button"
data-lk-component="button"
className={`${baseButtonClasses} ${modifiers || ''}`}
>
<div data-lk-button-content-wrap="true">
{startIcon && (
<div data-lk-icon-position="start">
<Icon
name={startIcon}
color={variant === 'fill' ? onColorToken : color}
data-lk-icon-position="start"
></Icon>
</div>
)}
<span data-lk-button-child="button-text">{label ?? 'Button'}</span>
{endIcon && (
<div data-lk-icon-position="end">
<Icon
name={endIcon}
color={variant === 'fill' ? onColorToken : color}
data-lk-icon-position="end"
></Icon>
</div>
)}
</div>
<StateLayer {...localStateLayerProps} />
</button>
)
}

View File

@@ -0,0 +1,238 @@
[data-lk-component='icon'] {
width: calc(1em * var(--lk-halfstep));
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
}
/* Regular families */
[data-lk-icon-font-class='display1'] {
--lineHeightInEms: calc(1em * var(--lk-quarterstep));
--md: var(--lineHeightInEms);
--sm: calc(var(--lineHeightInEms) * var(--lk-wholestep-dec));
--xs: calc(var(--lineHeightInEms) * var(--lk-halfstep-dec));
--2xs: calc(var(--lineHeightInEms) * var(--lk-quarterstep-dec));
--sm: calc(var(--lineHeightInEms) * calc(1 / var(--lk-halfstep)));
--lg: calc(var(--lineHeightInEms) * var(--lk-wholestep));
--xl: calc(var(--lk-size-lg) * var(--lk-wholestep));
--2xl: calc(var(--lk-size-xl) * var(--lk-wholestep));
letter-spacing: -0.022em;
font-size: var(--display1-font-size);
font-weight: 400;
line-height: var(--lk-quarterstep);
}
[data-lk-icon-font-class='display2'] {
--lineHeightInEms: calc(1em * var(--lk-halfstep));
--md: var(--lineHeightInEms);
--sm: calc(var(--lineHeightInEms) * var(--lk-wholestep-dec));
--xs: calc(var(--lineHeightInEms) * var(--lk-halfstep-dec));
--2xs: calc(var(--lineHeightInEms) * var(--lk-quarterstep-dec));
--lg: calc(var(--lineHeightInEms) * var(--lk-wholestep));
--xl: calc(var(--lk-size-lg) * var(--lk-wholestep));
--2xl: calc(var(--lk-size-xl) * var(--lk-wholestep));
letter-spacing: -0.022em;
font-size: var(--display2-font-size);
font-weight: 400;
line-height: var(--lk-halfstep);
}
[data-lk-icon-font-class='title1'] {
--lineHeightInEms: calc(1em * var(--lk-halfstep));
--md: var(--lineHeightInEms);
--sm: calc(var(--lineHeightInEms) * var(--lk-wholestep-dec));
--xs: calc(var(--lineHeightInEms) * var(--lk-halfstep-dec));
--2xs: calc(var(--lineHeightInEms) * var(--lk-quarterstep-dec));
--lg: calc(var(--lineHeightInEms) * var(--lk-wholestep));
--xl: calc(var(--lk-size-lg) * var(--lk-wholestep));
--2xl: calc(var(--lk-size-xl) * var(--lk-wholestep));
letter-spacing: -0.022em;
font-size: var(--title1-font-size);
font-weight: 400;
line-height: var(--lk-halfstep);
}
[data-lk-icon-font-class='title2'] {
--lineHeightInEms: calc(1em * var(--lk-halfstep));
--md: var(--lineHeightInEms);
--sm: calc(var(--lineHeightInEms) * var(--lk-wholestep-dec));
--xs: calc(var(--lineHeightInEms) * var(--lk-halfstep-dec));
--2xs: calc(var(--lineHeightInEms) * var(--lk-quarterstep-dec));
--lg: calc(var(--lineHeightInEms) * var(--lk-wholestep));
--xl: calc(var(--lk-size-lg) * var(--lk-wholestep));
--2xl: calc(var(--lk-size-xl) * var(--lk-wholestep));
letter-spacing: -0.02em;
font-size: var(--title2-font-size);
font-weight: 400;
line-height: var(--lk-halfstep);
}
[data-lk-icon-font-class='title3'] {
--lineHeightInEms: calc(1em * var(--lk-halfstep));
--md: var(--lineHeightInEms);
--sm: calc(var(--lineHeightInEms) * var(--lk-wholestep-dec));
--xs: calc(var(--lineHeightInEms) * var(--lk-halfstep-dec));
--2xs: calc(var(--lineHeightInEms) * var(--lk-quarterstep-dec));
--lg: calc(var(--lineHeightInEms) * var(--lk-wholestep));
--xl: calc(var(--lk-size-lg) * var(--lk-wholestep));
--2xl: calc(var(--lk-size-xl) * var(--lk-wholestep));
letter-spacing: -0.017em;
font-size: var(--title3-font-size);
font-weight: 400;
line-height: var(--lk-halfstep);
}
[data-lk-icon-font-class='heading'] {
--lineHeightInEms: calc(1em * var(--lk-halfstep));
--md: var(--lineHeightInEms);
--sm: calc(var(--lineHeightInEms) * var(--lk-wholestep-dec));
--xs: calc(var(--lineHeightInEms) * var(--lk-halfstep-dec));
--2xs: calc(var(--lineHeightInEms) * var(--lk-quarterstep-dec));
--lg: calc(var(--lineHeightInEms) * var(--lk-wholestep));
--xl: calc(var(--lk-size-lg) * var(--lk-wholestep));
--2xl: calc(var(--lk-size-xl) * var(--lk-wholestep));
letter-spacing: -0.014em;
font-size: var(--heading-font-size);
font-weight: 600;
line-height: var(--lk-halfstep);
}
[data-lk-icon-font-class='subheading'] {
--lineHeightInEms: calc(1em * var(--lk-halfstep));
--md: var(--lineHeightInEms);
--sm: calc(var(--lineHeightInEms) * var(--lk-wholestep-dec));
--xs: calc(var(--lineHeightInEms) * var(--lk-halfstep-dec));
--2xs: calc(var(--lineHeightInEms) * var(--lk-quarterstep-dec));
--lg: calc(var(--lineHeightInEms) * var(--lk-wholestep));
--xl: calc(var(--lk-size-lg) * var(--lk-wholestep));
--2xl: calc(var(--lk-size-xl) * var(--lk-wholestep));
letter-spacing: -0.007em;
font-size: var(--subheading-font-size);
font-weight: 400;
line-height: var(--lk-halfstep);
}
[data-lk-icon-font-class='body'] {
--lineHeightInEms: var(--title2-font-size);
--md: 1em;
--sm: calc(var(--lineHeightInEms) * var(--lk-wholestep-dec));
--xs: calc(var(--lineHeightInEms) * var(--lk-halfstep-dec));
--2xs: calc(var(--lineHeightInEms) * var(--lk-quarterstep-dec));
--lg: calc(1em * var(--lk-wholestep));
--xl: calc(var(--lk-size-lg) * var(--lk-wholestep));
--2xl: calc(var(--lk-size-xl) * var(--lk-wholestep));
letter-spacing: -0.011em;
cursor: default;
font-size: 1em;
font-weight: 400;
line-height: var(--lk-wholestep);
}
[data-lk-icon-font-class='callout'] {
--lineHeightInEms: calc(1em * var(--lk-halfstep));
--md: var(--lineHeightInEms);
--sm: calc(var(--lineHeightInEms) * var(--lk-wholestep-dec));
--xs: calc(var(--lineHeightInEms) * var(--lk-halfstep-dec));
--2xs: calc(var(--lineHeightInEms) * var(--lk-quarterstep-dec));
--lg: calc(var(--lineHeightInEms) * var(--lk-wholestep));
--xl: calc(var(--lk-size-lg) * var(--lk-wholestep));
--2xl: calc(var(--lk-size-xl) * var(--lk-wholestep));
letter-spacing: -0.009em;
font-size: var(--callout-font-size);
font-weight: 400;
line-height: var(--lk-halfstep);
}
[data-lk-icon-font-class='label'] {
--lineHeightInEms: calc(1em * var(--lk-halfstep));
--md: var(--lineHeightInEms);
--sm: calc(var(--lineHeightInEms) * var(--lk-wholestep-dec));
--xs: calc(var(--lineHeightInEms) * var(--lk-halfstep-dec));
--2xs: calc(var(--lineHeightInEms) * var(--lk-quarterstep-dec));
--lg: calc(var(--lineHeightInEms) * var(--lk-wholestep));
--xl: calc(var(--lk-size-lg) * var(--lk-wholestep));
--2xl: calc(var(--lk-size-xl) * var(--lk-wholestep));
letter-spacing: -0.004em;
font-size: var(--label-font-size);
font-weight: 600;
line-height: var(--lk-halfstep);
position: static;
}
[data-lk-icon-font-class='caption'] {
--lineHeightInEms: calc(1em * var(--lk-halfstep));
--md: var(--lineHeightInEms);
--sm: calc(var(--lineHeightInEms) * var(--lk-wholestep-dec));
--xs: calc(var(--lineHeightInEms) * var(--lk-halfstep-dec));
--2xs: calc(var(--lineHeightInEms) * var(--lk-quarterstep-dec));
--lg: calc(var(--lineHeightInEms) * var(--lk-wholestep));
--xl: calc(var(--lk-size-lg) * var(--lk-wholestep));
--2xl: calc(var(--lk-size-xl) * var(--lk-wholestep));
letter-spacing: -0.007em;
font-size: var(--caption-font-size);
font-weight: 400;
line-height: var(--lk-halfstep);
}
[data-lk-icon-font-class='capline'] {
--lineHeightInEms: calc(1em * var(--lk-halfstep));
--md: var(--lineHeightInEms);
--sm: calc(var(--lineHeightInEms) * var(--lk-wholestep-dec));
--xs: calc(var(--lineHeightInEms) * var(--lk-halfstep-dec));
--2xs: calc(var(--lineHeightInEms) * var(--lk-quarterstep-dec));
--lg: calc(var(--lineHeightInEms) * var(--lk-wholestep));
--xl: calc(var(--lk-size-lg) * var(--lk-wholestep));
--2xl: calc(var(--lk-size-xl) * var(--lk-wholestep));
letter-spacing: 0.0618em;
text-transform: uppercase;
font-size: var(--capline-font-size);
font-weight: 400;
line-height: var(--lk-halfstep);
}
/* Ignore the width and aspect ratio rules when inside an icon-button component */
[data-lk-component='icon-button'] [data-lk-component='icon'] {
width: unset;
aspect-ratio: unset;
}
[data-lk-icon-offset='true'] {
margin-top: calc(-1 * calc(1em * var(--lk-quarterstep-dec)));
}

View File

@@ -0,0 +1,38 @@
import { DynamicIcon } from 'lucide-react/dynamic'
import type { IconName } from 'lucide-react/dynamic'
import '@/components/icon/icon.css'
export interface LkIconProps extends React.HTMLAttributes<HTMLElement> {
name?: IconName
fontClass?: Exclude<LkFontClass, `${string}-bold` | `${string}-mono`>
color?: LkColor | 'currentColor'
display?: 'block' | 'inline-block' | 'inline'
strokeWidth?: number
opticShift?: boolean //if true, pulls icon slightly upward
}
export default function Icon({
name = 'roller-coaster',
fontClass,
color = 'onsurface',
strokeWidth = 2,
opticShift = false,
...restProps
}: LkIconProps) {
return (
<div
data-lk-component="icon"
data-lk-icon-offset={opticShift}
{...restProps}
data-lk-icon-font-class={fontClass}
>
<DynamicIcon
name={name}
width="1em"
height="1em"
color={`var(--lk-${color})`}
strokeWidth={strokeWidth}
/>
</div>
)
}

View File

@@ -0,0 +1,7 @@
<template>
<svg viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
<path
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
/>
</svg>
</template>

View File

@@ -0,0 +1,20 @@
<template>
<svg viewBox="0 0 24 24" class="w-5 h-5">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
</template>

View File

@@ -0,0 +1,259 @@
<template>
<nav :class="navClasses">
<div :class="containerClasses">
<div class="flex items-center justify-between h-full">
<!-- Left section -->
<div class="flex items-center space-x-4">
<!-- Logo/Brand -->
<slot name="brand">
<router-link
to="/"
class="flex items-center space-x-2 text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
<slot name="logo">
<div
class="w-8 h-8 bg-blue-600 dark:bg-blue-500 rounded-lg flex items-center justify-center"
>
<span class="text-white font-bold text-sm">T</span>
</div>
</slot>
<span class="font-semibold text-lg hidden sm:block">ThrillWiki</span>
</router-link>
</slot>
<!-- Navigation Links -->
<div class="hidden md:flex items-center space-x-1">
<slot name="nav-links">
<NavLink to="/parks" :active="$route.path.startsWith('/parks')"> Parks </NavLink>
<NavLink to="/rides" :active="$route.path.startsWith('/rides')"> Rides </NavLink>
<NavLink to="/search" :active="$route.path === '/search'"> Search </NavLink>
</slot>
</div>
</div>
<!-- Center section (optional) -->
<div class="flex-1 max-w-lg mx-4 hidden lg:block">
<slot name="center" />
</div>
<!-- Right section -->
<div class="flex items-center space-x-2">
<slot name="actions">
<!-- Search for mobile -->
<Button
v-if="showMobileSearch"
variant="ghost"
size="sm"
icon-only
class="md:hidden"
@click="$emit('toggle-search')"
aria-label="Toggle search"
>
<SearchIcon class="w-5 h-5" />
</Button>
<!-- Theme controller -->
<ThemeController v-if="showThemeToggle" variant="button" size="sm" />
<!-- User menu or auth buttons -->
<slot name="user-menu">
<Button variant="outline" size="sm" class="hidden sm:flex"> Sign In </Button>
</slot>
</slot>
<!-- Mobile menu button -->
<Button
v-if="showMobileMenu"
variant="ghost"
size="sm"
icon-only
class="md:hidden"
@click="toggleMobileMenu"
:aria-expanded="mobileMenuOpen"
aria-label="Toggle navigation menu"
>
<MenuIcon v-if="!mobileMenuOpen" class="w-5 h-5" />
<XIcon v-else class="w-5 h-5" />
</Button>
</div>
</div>
<!-- Mobile menu -->
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="mobileMenuOpen"
class="md:hidden border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800"
>
<div class="px-2 pt-2 pb-3 space-y-1">
<slot name="mobile-nav">
<MobileNavLink to="/parks" @click="closeMobileMenu"> Parks </MobileNavLink>
<MobileNavLink to="/rides" @click="closeMobileMenu"> Rides </MobileNavLink>
<MobileNavLink to="/search" @click="closeMobileMenu"> Search </MobileNavLink>
</slot>
</div>
<!-- Mobile user section -->
<div class="pt-4 pb-3 border-t border-gray-200 dark:border-gray-700">
<slot name="mobile-user">
<div class="px-2">
<Button variant="outline" size="sm" block @click="closeMobileMenu">
Sign In
</Button>
</div>
</slot>
</div>
</div>
</Transition>
</div>
</nav>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import Button from '../ui/Button.vue'
import ThemeController from './ThemeController.vue'
// Icons (using simple SVG icons)
const SearchIcon = {
template: `
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
`,
}
const MenuIcon = {
template: `
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
`,
}
const XIcon = {
template: `
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
`,
}
// NavLink component for desktop navigation
const NavLink = {
props: {
to: String,
active: Boolean,
},
template: `
<router-link
:to="to"
:class="[
'px-3 py-2 rounded-md text-sm font-medium transition-colors',
active
? 'bg-blue-50 text-blue-700 dark:bg-blue-900 dark:text-blue-200'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-300 dark:hover:text-gray-100 dark:hover:bg-gray-700'
]"
>
<slot />
</router-link>
`,
}
// MobileNavLink component for mobile navigation
const MobileNavLink = {
props: {
to: String,
},
template: `
<router-link
:to="to"
class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
@click="$emit('click')"
>
<slot />
</router-link>
`,
emits: ['click'],
}
// Props
interface NavbarProps {
sticky?: boolean
shadow?: boolean
height?: 'compact' | 'default' | 'comfortable'
showMobileSearch?: boolean
showThemeToggle?: boolean
showMobileMenu?: boolean
}
const props = withDefaults(defineProps<NavbarProps>(), {
sticky: true,
shadow: true,
height: 'default',
showMobileSearch: true,
showThemeToggle: true,
showMobileMenu: true,
})
// Emits
const emit = defineEmits<{
'toggle-search': []
'mobile-menu-open': []
'mobile-menu-close': []
}>()
// State
const mobileMenuOpen = ref(false)
// Computed classes
const navClasses = computed(() => {
let classes =
'bg-white border-b border-gray-200 dark:bg-gray-800 dark:border-gray-700 transition-colors duration-200'
if (props.sticky) {
classes += ' sticky top-0 z-50'
}
if (props.shadow) {
classes += ' shadow-sm'
}
return classes
})
const containerClasses = computed(() => {
let classes = 'max-w-7xl mx-auto px-4 sm:px-6 lg:px-8'
if (props.height === 'compact') {
classes += ' h-14'
} else if (props.height === 'comfortable') {
classes += ' h-20'
} else {
classes += ' h-16'
}
return classes
})
// Methods
const toggleMobileMenu = () => {
mobileMenuOpen.value = !mobileMenuOpen.value
if (mobileMenuOpen.value) {
emit('mobile-menu-open')
} else {
emit('mobile-menu-close')
}
}
const closeMobileMenu = () => {
mobileMenuOpen.value = false
emit('mobile-menu-close')
}
</script>

View File

@@ -0,0 +1,363 @@
<template>
<div :class="containerClasses">
<!-- Button variant -->
<button
v-if="variant === 'button'"
type="button"
:class="buttonClasses"
@click="toggleTheme"
:aria-label="currentTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
>
<!-- Sun icon (light mode) -->
<svg
v-if="currentTheme === 'dark'"
:class="iconClasses"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<!-- Moon icon (dark mode) -->
<svg v-else :class="iconClasses" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
<span v-if="showText" :class="textClasses">
{{ currentTheme === 'dark' ? 'Light' : 'Dark' }}
</span>
</button>
<!-- Dropdown variant -->
<div v-else-if="variant === 'dropdown'" class="relative">
<button
type="button"
:class="dropdownButtonClasses"
@click="dropdownOpen = !dropdownOpen"
@blur="handleBlur"
:aria-expanded="dropdownOpen"
aria-haspopup="true"
>
<!-- Current theme icon -->
<svg
v-if="currentTheme === 'dark'"
:class="iconClasses"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
<svg
v-else-if="currentTheme === 'light'"
:class="iconClasses"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<svg v-else :class="iconClasses" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
<span v-if="showText" :class="textClasses">
{{ currentTheme === 'dark' ? 'Dark' : currentTheme === 'light' ? 'Light' : 'Auto' }}
</span>
<!-- Dropdown arrow -->
<svg
v-if="showDropdown"
class="ml-2 h-4 w-4 transition-transform"
:class="{ 'rotate-180': dropdownOpen }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
<!-- Dropdown menu -->
<Transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-95 opacity-0"
>
<div
v-if="dropdownOpen"
:class="dropdownMenuClasses"
role="menu"
aria-orientation="vertical"
>
<!-- Light mode option -->
<button
type="button"
:class="dropdownItemClasses(currentTheme === 'light')"
@click="setTheme('light')"
role="menuitem"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
Light
</button>
<!-- Dark mode option -->
<button
type="button"
:class="dropdownItemClasses(currentTheme === 'dark')"
@click="setTheme('dark')"
role="menuitem"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
Dark
</button>
<!-- System mode option -->
<button
type="button"
:class="dropdownItemClasses(currentTheme === 'system')"
@click="setTheme('system')"
role="menuitem"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
System
</button>
</div>
</Transition>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
interface ThemeControllerProps {
variant?: 'button' | 'dropdown'
size?: 'sm' | 'md' | 'lg'
showText?: boolean
showDropdown?: boolean
position?: 'fixed' | 'relative'
}
const props = withDefaults(defineProps<ThemeControllerProps>(), {
variant: 'button',
size: 'md',
showText: false,
showDropdown: true,
position: 'relative',
})
// State
const currentTheme = ref<'light' | 'dark' | 'system'>('system')
const dropdownOpen = ref(false)
// Computed classes
const containerClasses = computed(() => {
return props.position === 'fixed' ? 'fixed top-4 right-4 z-50' : 'relative'
})
const sizeClasses = computed(() => {
const sizes = {
sm: {
button: 'h-8 px-2',
icon: 'h-4 w-4',
text: 'text-sm',
},
md: {
button: 'h-10 px-3',
icon: 'h-5 w-5',
text: 'text-sm',
},
lg: {
button: 'h-12 px-4',
icon: 'h-6 w-6',
text: 'text-base',
},
}
return sizes[props.size]
})
const buttonClasses = computed(() => {
return [
'inline-flex items-center justify-center rounded-md border border-gray-300 bg-white text-gray-700 transition-colors',
'hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
'dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700',
sizeClasses.value.button,
].join(' ')
})
const dropdownButtonClasses = computed(() => {
return [
'inline-flex items-center justify-center rounded-md border border-gray-300 bg-white text-gray-700 transition-colors',
'hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
'dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700',
sizeClasses.value.button,
].join(' ')
})
const dropdownMenuClasses = computed(() => {
return [
'absolute right-0 mt-2 w-48 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5',
'dark:bg-gray-800 dark:ring-gray-700',
'focus:outline-none z-50',
].join(' ')
})
const iconClasses = computed(() => {
let classes = sizeClasses.value.icon
if (props.showText) classes += ' mr-2'
return classes
})
const textClasses = computed(() => {
return `${sizeClasses.value.text} font-medium`
})
// Dropdown item classes
const dropdownItemClasses = (isActive: boolean) => {
const baseClasses = 'flex w-full items-center px-4 py-2 text-left text-sm transition-colors'
const activeClasses = isActive
? 'bg-blue-50 text-blue-700 dark:bg-blue-900 dark:text-blue-200'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
return `${baseClasses} ${activeClasses}`
}
// Theme management
const applyTheme = (theme: 'light' | 'dark') => {
if (theme === 'dark') {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}
const getSystemTheme = (): 'light' | 'dark' => {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
const setTheme = (theme: 'light' | 'dark' | 'system') => {
currentTheme.value = theme
localStorage.setItem('theme', theme)
if (theme === 'system') {
applyTheme(getSystemTheme())
} else {
applyTheme(theme)
}
dropdownOpen.value = false
}
const toggleTheme = () => {
if (currentTheme.value === 'light') {
setTheme('dark')
} else {
setTheme('light')
}
}
const handleBlur = (event: FocusEvent) => {
// Close dropdown if focus moves outside
const relatedTarget = event.relatedTarget as Element
if (!relatedTarget || !relatedTarget.closest('[role="menu"]')) {
dropdownOpen.value = false
}
}
// Initialize theme
onMounted(() => {
const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | 'system' | null
if (savedTheme) {
currentTheme.value = savedTheme
} else {
currentTheme.value = 'system'
}
if (currentTheme.value === 'system') {
applyTheme(getSystemTheme())
} else {
applyTheme(currentTheme.value)
}
// Listen for system theme changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleSystemThemeChange = () => {
if (currentTheme.value === 'system') {
applyTheme(getSystemTheme())
}
}
mediaQuery.addEventListener('change', handleSystemThemeChange)
// Cleanup
return () => {
mediaQuery.removeEventListener('change', handleSystemThemeChange)
}
})
// Watch for theme changes
watch(currentTheme, (newTheme) => {
if (newTheme === 'system') {
applyTheme(getSystemTheme())
} else {
applyTheme(newTheme)
}
})
</script>

View File

@@ -0,0 +1,19 @@
import '@/components/state-layer/state-layer.css'
export interface LkStateLayerProps {
bgColor?: LkColor | 'currentColor'
forcedState?: 'hover' | 'active' | 'focus' // Used when you need a static state controlled by something higher, like a select field that keeps actively-selected options grayed out
}
export default function StateLayer({ bgColor = 'currentColor', forcedState }: LkStateLayerProps) {
return (
<>
<div
data-lk-component="state-layer"
className={bgColor !== 'currentColor' ? `bg-${bgColor}` : ''}
style={bgColor === 'currentColor' ? { backgroundColor: 'currentColor' } : {}}
{...(forcedState && { 'data-lk-forced-state': forcedState })}
></div>
</>
)
}

View File

@@ -0,0 +1,46 @@
[data-lk-component='state-layer'] {
z-index: 1;
opacity: 0;
pointer-events: none;
position: absolute;
top: 0%;
bottom: 0%;
left: 0%;
right: 0%;
transition: opacity 0.1s ease-out;
}
/* Only apply styles to the [data-lk-component="state-layer"] when its direct parent is hovered, active, or focused */
div:hover > [data-lk-component='state-layer'],
a:hover > [data-lk-component='state-layer'],
button:hover > [data-lk-component='state-layer'],
li:hover > [data-lk-component='state-layer'] {
opacity: 0.16 !important;
}
div:active > [data-lk-component='state-layer'],
a:active > [data-lk-component='state-layer'],
button:active > [data-lk-component='state-layer'],
li:active > [data-lk-component='state-layer'] {
opacity: 0.5 !important;
}
div:focus > [data-lk-component='state-layer'],
a:focus > [data-lk-component='state-layer'],
button:focus > [data-lk-component='state-layer'],
li:focus > [data-lk-component='state-layer'] {
opacity: 0.35 !important;
}
.is-active [data-lk-component='state-layer'] {
opacity: 0.16 !important;
}
.list-item.active [data-lk-component='state-layer'] {
opacity: 0.24 !important;
}
[data-lk-forced-state='active'] {
opacity: 0.12 !important;
}

View File

@@ -0,0 +1,123 @@
<template>
<span :class="badgeClasses">
<slot />
<button
v-if="removable"
@click="$emit('remove')"
:class="removeButtonClasses"
type="button"
:aria-label="`Remove ${$slots.default?.[0]?.children || 'badge'}`"
>
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface BadgeProps {
variant?: 'default' | 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info'
size?: 'sm' | 'md' | 'lg'
rounded?: boolean
outline?: boolean
removable?: boolean
}
const props = withDefaults(defineProps<BadgeProps>(), {
variant: 'default',
size: 'md',
rounded: true,
outline: false,
removable: false,
})
const emit = defineEmits<{
remove: []
}>()
// Base badge classes
const baseClasses = 'inline-flex items-center font-medium transition-colors'
// Variant classes
const variantClasses = computed(() => {
if (props.outline) {
const outlineVariants = {
default:
'border border-gray-300 text-gray-700 bg-transparent dark:border-gray-600 dark:text-gray-300',
primary:
'border border-blue-300 text-blue-700 bg-transparent dark:border-blue-600 dark:text-blue-300',
secondary:
'border border-gray-300 text-gray-600 bg-transparent dark:border-gray-600 dark:text-gray-400',
success:
'border border-green-300 text-green-700 bg-transparent dark:border-green-600 dark:text-green-300',
warning:
'border border-yellow-300 text-yellow-700 bg-transparent dark:border-yellow-600 dark:text-yellow-300',
error:
'border border-red-300 text-red-700 bg-transparent dark:border-red-600 dark:text-red-300',
info: 'border border-cyan-300 text-cyan-700 bg-transparent dark:border-cyan-600 dark:text-cyan-300',
}
return outlineVariants[props.variant]
}
const variants = {
default: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200',
primary: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
secondary: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400',
success: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
warning: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
error: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
info: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-200',
}
return variants[props.variant]
})
// Size classes
const sizeClasses = computed(() => {
const sizes = {
sm: 'px-2 py-0.5 text-xs',
md: 'px-2.5 py-1 text-sm',
lg: 'px-3 py-1.5 text-base',
}
return sizes[props.size]
})
// Rounded classes
const roundedClasses = computed(() => {
if (!props.rounded) return 'rounded-none'
const rounded = {
sm: 'rounded-md',
md: 'rounded-lg',
lg: 'rounded-xl',
}
return rounded[props.size]
})
// Remove button classes
const removeButtonClasses = computed(() => {
let classes =
'ml-1 inline-flex items-center justify-center rounded-full hover:bg-black/10 dark:hover:bg-white/10 transition-colors'
if (props.size === 'sm') {
classes += ' h-4 w-4'
} else if (props.size === 'md') {
classes += ' h-5 w-5'
} else {
classes += ' h-6 w-6'
}
return classes
})
// Combined badge classes
const badgeClasses = computed(() => {
return [baseClasses, variantClasses.value, sizeClasses.value, roundedClasses.value].join(' ')
})
</script>

View File

@@ -0,0 +1,172 @@
<template>
<component
:is="componentTag"
:class="buttonClasses"
:disabled="disabled || loading"
:type="type"
:href="href"
:target="target"
:to="to"
@click="handleClick"
>
<!-- Loading spinner -->
<div
v-if="loading"
class="animate-spin -ml-1 mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"
:class="{ 'mr-0': iconOnly }"
/>
<!-- Start icon -->
<component v-if="iconStart && !loading" :is="iconStart" :class="iconClasses" />
<!-- Button text -->
<span v-if="!iconOnly" :class="{ 'sr-only': loading }">
<slot />
</span>
<!-- End icon -->
<component v-if="iconEnd && !loading" :is="iconEnd" :class="iconClasses" />
</component>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'link' | 'destructive'
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
disabled?: boolean
loading?: boolean
block?: boolean
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'full'
iconStart?: any
iconEnd?: any
iconOnly?: boolean
href?: string
to?: string | object
target?: string
type?: 'button' | 'submit' | 'reset'
}
const props = withDefaults(defineProps<ButtonProps>(), {
variant: 'primary',
size: 'md',
disabled: false,
loading: false,
block: false,
rounded: 'md',
iconOnly: false,
type: 'button',
})
const emit = defineEmits<{
click: [event: Event]
}>()
// Determine component tag
const componentTag = computed(() => {
if (props.href) return 'a'
if (props.to) return 'router-link'
return 'button'
})
// Base button classes
const baseClasses =
'inline-flex items-center justify-center font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50'
// Variant classes
const variantClasses = computed(() => {
const variants = {
primary:
'bg-blue-600 text-white hover:bg-blue-700 active:bg-blue-800 dark:bg-blue-500 dark:hover:bg-blue-600',
secondary:
'bg-gray-100 text-gray-900 hover:bg-gray-200 active:bg-gray-300 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700',
outline:
'border border-gray-300 bg-transparent text-gray-700 hover:bg-gray-50 active:bg-gray-100 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800',
ghost:
'bg-transparent text-gray-700 hover:bg-gray-100 active:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-800',
link: 'bg-transparent text-blue-600 underline-offset-4 hover:underline dark:text-blue-400',
destructive:
'bg-red-600 text-white hover:bg-red-700 active:bg-red-800 dark:bg-red-500 dark:hover:bg-red-600',
}
return variants[props.variant]
})
// Size classes
const sizeClasses = computed(() => {
if (props.iconOnly) {
const iconOnlySizes = {
xs: 'h-6 w-6 p-1',
sm: 'h-8 w-8 p-1.5',
md: 'h-10 w-10 p-2',
lg: 'h-12 w-12 p-2.5',
xl: 'h-14 w-14 p-3',
}
return iconOnlySizes[props.size]
}
const sizes = {
xs: 'h-6 px-2 py-1 text-xs',
sm: 'h-8 px-3 py-1.5 text-sm',
md: 'h-10 px-4 py-2 text-sm',
lg: 'h-12 px-6 py-3 text-base',
xl: 'h-14 px-8 py-4 text-lg',
}
return sizes[props.size]
})
// Rounded classes
const roundedClasses = computed(() => {
const rounded = {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
full: 'rounded-full',
}
return rounded[props.rounded]
})
// Block classes
const blockClasses = computed(() => {
return props.block ? 'w-full' : ''
})
// Icon classes
const iconClasses = computed(() => {
const iconSizes = {
xs: 'h-3 w-3',
sm: 'h-4 w-4',
md: 'h-4 w-4',
lg: 'h-5 w-5',
xl: 'h-6 w-6',
}
let classes = iconSizes[props.size]
if (!props.iconOnly) {
if (props.iconStart) classes += ' mr-2'
if (props.iconEnd) classes += ' ml-2'
}
return classes
})
// Combined button classes
const buttonClasses = computed(() => {
return [
baseClasses,
variantClasses.value,
sizeClasses.value,
roundedClasses.value,
blockClasses.value,
].join(' ')
})
// Handle click events
const handleClick = (event: Event) => {
if (!props.disabled && !props.loading) {
emit('click', event)
}
}
</script>

View File

@@ -0,0 +1,206 @@
<template>
<div :class="cardClasses">
<!-- Header -->
<div v-if="title || $slots.header" :class="headerClasses">
<slot name="header">
<h3 v-if="title" :class="titleClasses">
{{ title }}
</h3>
</slot>
</div>
<!-- Content -->
<div v-if="$slots.default" :class="contentClasses">
<slot />
</div>
<!-- Footer -->
<div v-if="$slots.footer" :class="footerClasses">
<slot name="footer" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface CardProps {
variant?: 'default' | 'outline' | 'ghost' | 'elevated'
size?: 'sm' | 'md' | 'lg'
title?: string
padding?: 'none' | 'sm' | 'md' | 'lg'
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl'
shadow?: 'none' | 'sm' | 'md' | 'lg' | 'xl'
hover?: boolean
interactive?: boolean
}
const props = withDefaults(defineProps<CardProps>(), {
variant: 'default',
size: 'md',
padding: 'md',
rounded: 'lg',
shadow: 'sm',
hover: false,
interactive: false,
})
// Base card classes
const baseClasses =
'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 transition-all duration-200'
// Variant classes
const variantClasses = computed(() => {
const variants = {
default: 'border',
outline: 'border-2',
ghost: 'border-0 bg-transparent dark:bg-transparent',
elevated: 'border-0',
}
return variants[props.variant]
})
// Shadow classes
const shadowClasses = computed(() => {
if (props.variant === 'ghost') return ''
const shadows = {
none: '',
sm: 'shadow-sm',
md: 'shadow-md',
lg: 'shadow-lg',
xl: 'shadow-xl',
}
return shadows[props.shadow]
})
// Rounded classes
const roundedClasses = computed(() => {
const rounded = {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
xl: 'rounded-xl',
}
return rounded[props.rounded]
})
// Hover classes
const hoverClasses = computed(() => {
if (!props.hover && !props.interactive) return ''
let classes = ''
if (props.hover) {
classes += ' hover:shadow-md'
if (props.variant !== 'ghost') {
classes += ' hover:border-gray-300 dark:hover:border-gray-600'
}
}
if (props.interactive) {
classes += ' cursor-pointer hover:scale-[1.02] active:scale-[0.98]'
}
return classes
})
// Padding classes for different sections
const paddingClasses = computed(() => {
const paddings = {
none: '',
sm: 'p-3',
md: 'p-4',
lg: 'p-6',
}
return paddings[props.padding]
})
const headerPadding = computed(() => {
if (props.padding === 'none') return ''
const paddings = {
sm: 'px-3 pt-3',
md: 'px-4 pt-4',
lg: 'px-6 pt-6',
}
return paddings[props.padding]
})
const contentPadding = computed(() => {
if (props.padding === 'none') return ''
const hasHeader = props.title || props.$slots?.header
const hasFooter = props.$slots?.footer
let classes = ''
if (props.padding === 'sm') {
classes = 'px-3'
if (!hasHeader) classes += ' pt-3'
if (!hasFooter) classes += ' pb-3'
} else if (props.padding === 'md') {
classes = 'px-4'
if (!hasHeader) classes += ' pt-4'
if (!hasFooter) classes += ' pb-4'
} else if (props.padding === 'lg') {
classes = 'px-6'
if (!hasHeader) classes += ' pt-6'
if (!hasFooter) classes += ' pb-6'
}
return classes
})
const footerPadding = computed(() => {
if (props.padding === 'none') return ''
const paddings = {
sm: 'px-3 pb-3',
md: 'px-4 pb-4',
lg: 'px-6 pb-6',
}
return paddings[props.padding]
})
// Combined classes
const cardClasses = computed(() => {
return [
baseClasses,
variantClasses.value,
shadowClasses.value,
roundedClasses.value,
hoverClasses.value,
props.padding === 'none' ? '' : '',
]
.filter(Boolean)
.join(' ')
})
const headerClasses = computed(() => {
let classes = headerPadding.value
if (props.padding !== 'none') {
classes += ' border-b border-gray-200 dark:border-gray-700'
}
return classes
})
const contentClasses = computed(() => {
return contentPadding.value
})
const footerClasses = computed(() => {
let classes = footerPadding.value
if (props.padding !== 'none') {
classes += ' border-t border-gray-200 dark:border-gray-700'
}
return classes
})
const titleClasses = computed(() => {
const sizes = {
sm: 'text-lg font-semibold',
md: 'text-xl font-semibold',
lg: 'text-2xl font-semibold',
}
return `${sizes[props.size]} text-gray-900 dark:text-gray-100`
})
</script>

View File

@@ -0,0 +1,297 @@
<template>
<div :class="containerClasses">
<!-- Label -->
<label v-if="label" :for="inputId" :class="labelClasses">
{{ label }}
</label>
<!-- Input container -->
<div class="relative">
<!-- Search icon -->
<div :class="searchIconClasses">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<!-- Input field -->
<input
:id="inputId"
ref="inputRef"
:type="type"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:class="inputClasses"
:aria-label="ariaLabel"
:aria-describedby="helpTextId"
@input="handleInput"
@keydown.enter="handleEnter"
@focus="focused = true"
@blur="focused = false"
/>
<!-- Clear button -->
<button
v-if="clearable && modelValue && !disabled && !readonly"
type="button"
:class="clearButtonClasses"
:aria-label="clearButtonLabel"
@click="handleClear"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<!-- Search button -->
<button
v-if="searchButton && (!searchButton || searchButton === 'icon' || searchButton === 'text')"
type="button"
:class="searchButtonClasses"
:aria-label="searchButtonLabel"
:disabled="disabled || (!allowEmptySearch && !modelValue?.trim())"
@click="handleSearch"
>
<svg
v-if="searchButton === 'icon' || searchButton === true"
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<span v-if="searchButton === 'text'">Search</span>
</button>
</div>
<!-- Help text -->
<p v-if="helpText" :id="helpTextId" :class="helpTextClasses">
{{ helpText }}
</p>
<!-- Error message -->
<p v-if="error" :class="errorClasses">
{{ error }}
</p>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
interface SearchInputProps {
modelValue?: string
type?: 'search' | 'text'
placeholder?: string
label?: string
disabled?: boolean
readonly?: boolean
clearable?: boolean
searchButton?: boolean | 'icon' | 'text'
allowEmptySearch?: boolean
size?: 'sm' | 'md' | 'lg'
variant?: 'default' | 'outline'
error?: string
helpText?: string
ariaLabel?: string
clearButtonLabel?: string
searchButtonLabel?: string
debounceMs?: number
}
const props = withDefaults(defineProps<SearchInputProps>(), {
type: 'search',
placeholder: 'Search...',
clearable: true,
searchButton: false,
allowEmptySearch: true,
size: 'md',
variant: 'default',
clearButtonLabel: 'Clear search',
searchButtonLabel: 'Search',
debounceMs: 300,
})
const emit = defineEmits<{
'update:modelValue': [value: string]
search: [value: string]
clear: []
input: [value: string]
}>()
// Refs
const inputRef = ref<HTMLInputElement>()
const focused = ref(false)
const debounceTimer = ref<number>()
// Computed IDs
const inputId = computed(() => `search-input-${Math.random().toString(36).substr(2, 9)}`)
const helpTextId = computed(() => `${inputId.value}-help`)
// Container classes
const containerClasses = computed(() => {
return 'space-y-1'
})
// Label classes
const labelClasses = computed(() => {
return 'block text-sm font-medium text-gray-700 dark:text-gray-300'
})
// Size-based classes
const sizeClasses = computed(() => {
const sizes = {
sm: {
input: 'h-8 text-sm',
padding: 'pl-8 pr-8',
icon: 'left-2',
button: 'right-1 h-6 w-6',
},
md: {
input: 'h-10 text-sm',
padding: 'pl-10 pr-10',
icon: 'left-3',
button: 'right-2 h-6 w-6',
},
lg: {
input: 'h-12 text-base',
padding: 'pl-12 pr-12',
icon: 'left-4',
button: 'right-3 h-8 w-8',
},
}
return sizes[props.size]
})
// Input classes
const inputClasses = computed(() => {
const baseClasses =
'block w-full rounded-md border transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2'
let variantClasses = ''
if (props.variant === 'outline') {
variantClasses = 'border-gray-300 bg-transparent dark:border-gray-600 dark:bg-transparent'
} else {
variantClasses = 'border-gray-300 bg-white dark:border-gray-600 dark:bg-gray-800'
}
const stateClasses = props.error
? 'border-red-500 focus:border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:border-blue-500 dark:border-gray-600 dark:focus:border-blue-400'
const disabledClasses = props.disabled ? 'opacity-50 cursor-not-allowed' : ''
const textClasses =
'text-gray-900 placeholder-gray-500 dark:text-gray-100 dark:placeholder-gray-400'
return [
baseClasses,
variantClasses,
stateClasses,
disabledClasses,
textClasses,
sizeClasses.value.input,
sizeClasses.value.padding,
].join(' ')
})
// Search icon classes
const searchIconClasses = computed(() => {
return `absolute ${sizeClasses.value.icon} top-1/2 transform -translate-y-1/2 text-gray-400 dark:text-gray-500 pointer-events-none`
})
// Clear button classes
const clearButtonClasses = computed(() => {
return `absolute ${sizeClasses.value.button} top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition-colors rounded-full p-1 hover:bg-gray-100 dark:hover:bg-gray-700`
})
// Search button classes
const searchButtonClasses = computed(() => {
const baseClasses = `absolute ${sizeClasses.value.button} top-1/2 transform -translate-y-1/2 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed`
if (props.searchButton === 'text') {
return `${baseClasses} bg-blue-600 text-white hover:bg-blue-700 px-3 py-1 text-sm font-medium`
}
return `${baseClasses} text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 p-1 hover:bg-gray-100 dark:hover:bg-gray-700`
})
// Help text classes
const helpTextClasses = computed(() => {
return 'text-sm text-gray-500 dark:text-gray-400'
})
// Error classes
const errorClasses = computed(() => {
return 'text-sm text-red-600 dark:text-red-400'
})
// Methods
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement
const value = target.value
emit('update:modelValue', value)
emit('input', value)
// Debounced search
if (props.debounceMs > 0) {
clearTimeout(debounceTimer.value)
debounceTimer.value = window.setTimeout(() => {
if (props.allowEmptySearch || value.trim()) {
emit('search', value)
}
}, props.debounceMs)
}
}
const handleEnter = (event: KeyboardEvent) => {
if (!props.searchButton) {
handleSearch()
}
}
const handleSearch = () => {
const value = props.modelValue || ''
if (props.allowEmptySearch || value.trim()) {
emit('search', value)
}
}
const handleClear = async () => {
emit('update:modelValue', '')
emit('clear')
emit('search', '')
await nextTick()
inputRef.value?.focus()
}
// Focus method
const focus = () => {
inputRef.value?.focus()
}
// Expose focus method
defineExpose({
focus,
})
</script>

View File

@@ -0,0 +1,82 @@
// UI Components
export { default as Badge } from './Badge.vue'
export { default as Button } from './Button.vue'
export { default as Card } from './Card.vue'
export { default as SearchInput } from './SearchInput.vue'
// Layout Components
export { default as ThemeController } from '../layout/ThemeController.vue'
export { default as Navbar } from '../layout/Navbar.vue'
// Type definitions for component props
export interface BadgeProps {
variant?: 'default' | 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info'
size?: 'sm' | 'md' | 'lg'
rounded?: boolean
outline?: boolean
removable?: boolean
}
export interface ButtonProps {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'link' | 'destructive'
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
disabled?: boolean
loading?: boolean
block?: boolean
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'full'
iconStart?: any
iconEnd?: any
iconOnly?: boolean
href?: string
to?: string | object
target?: string
type?: 'button' | 'submit' | 'reset'
}
export interface CardProps {
variant?: 'default' | 'outline' | 'ghost' | 'elevated'
size?: 'sm' | 'md' | 'lg'
title?: string
padding?: 'none' | 'sm' | 'md' | 'lg'
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl'
shadow?: 'none' | 'sm' | 'md' | 'lg' | 'xl'
hover?: boolean
interactive?: boolean
}
export interface SearchInputProps {
modelValue?: string
type?: 'search' | 'text'
placeholder?: string
label?: string
disabled?: boolean
readonly?: boolean
clearable?: boolean
searchButton?: boolean | 'icon' | 'text'
allowEmptySearch?: boolean
size?: 'sm' | 'md' | 'lg'
variant?: 'default' | 'outline'
error?: string
helpText?: string
ariaLabel?: string
clearButtonLabel?: string
searchButtonLabel?: string
debounceMs?: number
}
export interface ThemeControllerProps {
variant?: 'button' | 'dropdown'
size?: 'sm' | 'md' | 'lg'
showText?: boolean
showDropdown?: boolean
position?: 'fixed' | 'relative'
}
export interface NavbarProps {
sticky?: boolean
shadow?: boolean
height?: 'compact' | 'default' | 'comfortable'
showMobileSearch?: boolean
showThemeToggle?: boolean
showMobileMenu?: boolean
}

View File

@@ -0,0 +1,201 @@
import { ref, computed } from 'vue'
import { authApi, type AuthResponse, type User } from '@/services/api'
import type { LoginCredentials, SignupCredentials } from '@/types'
// Global authentication state
const currentUser = ref<User | null>(null)
const authToken = ref<string | null>(localStorage.getItem('auth_token'))
const isLoading = ref(false)
const authError = ref<string | null>(null)
// Computed properties
const isAuthenticated = computed(() => !!currentUser.value && !!authToken.value)
// Authentication composable
export function useAuth() {
/**
* Initialize authentication state
*/
const initAuth = async () => {
const token = localStorage.getItem('auth_token')
if (token) {
authToken.value = token
authApi.setAuthToken(token)
await getCurrentUser()
}
}
/**
* Get current user information
*/
const getCurrentUser = async () => {
if (!authToken.value) return null
try {
const user = await authApi.getCurrentUser()
currentUser.value = user
return user
} catch (error) {
console.error('Failed to get current user:', error)
await logout()
return null
}
}
/**
* Login with username/email and password
*/
const login = async (credentials: LoginCredentials): Promise<AuthResponse> => {
try {
isLoading.value = true
authError.value = null
const response = await authApi.login(credentials)
// Store authentication data
authToken.value = response.token
currentUser.value = response.user
localStorage.setItem('auth_token', response.token)
return response
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Login failed'
authError.value = errorMessage
throw error
} finally {
isLoading.value = false
}
}
/**
* Register new user
*/
const signup = async (credentials: SignupCredentials): Promise<AuthResponse> => {
try {
isLoading.value = true
authError.value = null
const response = await authApi.signup(credentials)
// Store authentication data
authToken.value = response.token
currentUser.value = response.user
localStorage.setItem('auth_token', response.token)
return response
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Registration failed'
authError.value = errorMessage
throw error
} finally {
isLoading.value = false
}
}
/**
* Logout user
*/
const logout = async () => {
try {
if (authToken.value) {
await authApi.logout()
}
} catch (error) {
console.error('Logout error:', error)
} finally {
// Clear local state regardless of API call success
currentUser.value = null
authToken.value = null
localStorage.removeItem('auth_token')
authApi.setAuthToken(null)
}
}
/**
* Request password reset
*/
const requestPasswordReset = async (email: string) => {
try {
isLoading.value = true
authError.value = null
const response = await authApi.requestPasswordReset({ email })
return response
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Password reset request failed'
authError.value = errorMessage
throw error
} finally {
isLoading.value = false
}
}
/**
* Change password
*/
const changePassword = async (oldPassword: string, newPassword: string) => {
try {
isLoading.value = true
authError.value = null
const response = await authApi.changePassword({
old_password: oldPassword,
new_password: newPassword,
new_password_confirm: newPassword,
})
return response
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Password change failed'
authError.value = errorMessage
throw error
} finally {
isLoading.value = false
}
}
/**
* Get available social providers
*/
const getSocialProviders = async () => {
try {
return await authApi.getSocialProviders()
} catch (error) {
console.error('Failed to get social providers:', error)
return []
}
}
/**
* Clear authentication error
*/
const clearError = () => {
authError.value = null
}
return {
// State
currentUser: computed(() => currentUser.value),
authToken: computed(() => authToken.value),
isLoading: computed(() => isLoading.value),
authError: computed(() => authError.value),
isAuthenticated,
// Methods
initAuth,
getCurrentUser,
login,
signup,
logout,
requestPasswordReset,
changePassword,
getSocialProviders,
clearError,
}
}
// Initialize auth state on app startup
const auth = useAuth()
auth.initAuth()
export default auth

View File

@@ -1,9 +1,11 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
import './assets/styles/globals.css'
import App from './App.vue'
import router from './router'
// Import Tailwind CSS
import './style.css'
const app = createApp(App)

View File

@@ -0,0 +1,104 @@
import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
import ParkList from '@/views/parks/ParkList.vue'
import ParkDetail from '@/views/parks/ParkDetail.vue'
import RideList from '@/views/rides/RideList.vue'
import RideDetail from '@/views/rides/RideDetail.vue'
import SearchResults from '@/views/SearchResults.vue'
import Login from '@/views/accounts/Login.vue'
import Signup from '@/views/accounts/Signup.vue'
import ForgotPassword from '@/views/accounts/ForgotPassword.vue'
import NotFound from '@/views/NotFound.vue'
import Error from '@/views/Error.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: Home,
},
{
path: '/parks/',
name: 'park-list',
component: ParkList,
},
{
path: '/parks/:slug/',
name: 'park-detail',
component: ParkDetail,
props: true,
},
{
path: '/parks/:parkSlug/rides/',
name: 'park-ride-list',
component: RideList,
props: true,
},
{
path: '/parks/:parkSlug/rides/:rideSlug/',
name: 'ride-detail',
component: RideDetail,
props: true,
},
{
path: '/rides/',
name: 'global-ride-list',
component: RideList,
},
{
path: '/rides/:rideSlug/',
name: 'global-ride-detail',
component: RideDetail,
props: true,
},
{
path: '/search/',
name: 'search-results',
component: SearchResults,
},
{
path: '/search/parks/',
name: 'search-parks',
component: SearchResults,
props: { searchType: 'parks' },
},
{
path: '/search/rides/',
name: 'search-rides',
component: SearchResults,
props: { searchType: 'rides' },
},
// Authentication routes
{
path: '/auth/login/',
name: 'login',
component: Login,
},
{
path: '/auth/signup/',
name: 'signup',
component: Signup,
},
{
path: '/auth/forgot-password/',
name: 'forgot-password',
component: ForgotPassword,
},
// Error routes
{
path: '/error/',
name: 'error',
component: Error,
},
// 404 catch-all route (must be last)
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: NotFound,
},
],
})
export default router

View File

@@ -0,0 +1,800 @@
/**
* API service for communicating with Django backend
*/
import type {
Park,
Ride,
User,
LoginCredentials,
SignupCredentials,
AuthResponse,
PasswordResetRequest,
PasswordChangeRequest,
SocialAuthProvider,
} from '@/types'
// History-specific types
export interface HistoryEvent {
id: string
pgh_created_at: string
pgh_label: 'created' | 'updated' | 'deleted'
pgh_model: string
pgh_obj_id: number
pgh_context?: {
user_id?: number
request_id?: string
ip_address?: string
}
changed_fields?: string[]
field_changes?: Record<string, {
old_value: any
new_value: any
}>
}
export interface UnifiedHistoryEvent {
id: string
pgh_created_at: string
pgh_label: 'created' | 'updated' | 'deleted'
pgh_model: string
pgh_obj_id: number
entity_name: string
entity_slug: string
change_significance: 'major' | 'minor' | 'routine'
change_summary: string
}
export interface HistorySummary {
total_events: number
first_recorded: string | null
last_modified: string | null
significant_changes?: Array<{
date: string
event_type: string
description: string
}>
}
export interface ParkHistoryResponse {
park: Park
current_state: Park
summary: HistorySummary
events: HistoryEvent[]
}
export interface RideHistoryResponse {
ride: Ride
current_state: Ride
summary: HistorySummary
events: HistoryEvent[]
}
export interface UnifiedHistoryTimeline {
summary: {
total_events: number
events_returned: number
event_type_breakdown: Record<string, number>
model_type_breakdown: Record<string, number>
time_range: {
earliest: string | null
latest: string | null
}
}
events: UnifiedHistoryEvent[]
}
export interface HistoryParams {
limit?: number
offset?: number
event_type?: 'created' | 'updated' | 'deleted'
start_date?: string
end_date?: string
model_type?: 'park' | 'ride' | 'company' | 'user'
significance?: 'major' | 'minor' | 'routine'
}
// API configuration
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ''
// API response types
interface ApiResponse<T> {
count: number
next: string | null
previous: string | null
results: T[]
}
interface SearchResponse<T> {
results: T[]
count: number
query: string
}
/**
* Base API client with common functionality
*/
class ApiClient {
private baseUrl: string
private authToken: string | null = null
constructor(baseUrl: string = API_BASE_URL) {
this.baseUrl = baseUrl
// Check for existing auth token
this.authToken = localStorage.getItem('auth_token')
}
/**
* Set authentication token
*/
setAuthToken(token: string | null) {
this.authToken = token
if (token) {
localStorage.setItem('auth_token', token)
} else {
localStorage.removeItem('auth_token')
}
}
/**
* Get authentication token
*/
getAuthToken(): string | null {
return this.authToken
}
/**
* Make HTTP request with error handling
*/
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const url = `${this.baseUrl}${endpoint}`
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json',
}
// Add auth token if available
if (this.authToken) {
headers['Authorization'] = `Token ${this.authToken}`
}
// Add CSRF token for state-changing requests
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(options.method || 'GET')) {
const csrfToken = this.getCSRFToken()
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken
}
}
const defaultOptions: RequestInit = {
headers,
credentials: 'include', // Include cookies for session auth
...options,
}
try {
const response = await fetch(url, defaultOptions)
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(
errorData.detail || errorData.message || `HTTP error! status: ${response.status}`,
)
}
return await response.json()
} catch (error) {
console.error(`API request failed for ${endpoint}:`, error)
throw error
}
}
/**
* Get CSRF token from cookies
*/
private getCSRFToken(): string | null {
const name = 'csrftoken'
if (document.cookie) {
const cookies = document.cookie.split(';')
for (let cookie of cookies) {
cookie = cookie.trim()
if (cookie.substring(0, name.length + 1) === name + '=') {
return decodeURIComponent(cookie.substring(name.length + 1))
}
}
}
return null
}
/**
* GET request
*/
async get<T>(endpoint: string, params?: Record<string, string>): Promise<T> {
let url = endpoint
if (params) {
const searchParams = new URLSearchParams(params)
url += `?${searchParams.toString()}`
}
return this.request<T>(url, { method: 'GET' })
}
/**
* POST request
*/
async post<T>(endpoint: string, data?: any): Promise<T> {
return this.request<T>(endpoint, {
method: 'POST',
body: data ? JSON.stringify(data) : undefined,
})
}
/**
* PUT request
*/
async put<T>(endpoint: string, data?: any): Promise<T> {
return this.request<T>(endpoint, {
method: 'PUT',
body: data ? JSON.stringify(data) : undefined,
})
}
/**
* DELETE request
*/
async delete<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, { method: 'DELETE' })
}
}
/**
* Parks API service
*/
export class ParksApi {
private client: ApiClient
constructor(client: ApiClient = new ApiClient()) {
this.client = client
}
/**
* Get all parks with pagination
*/
async getParks(params?: {
page?: number
search?: string
ordering?: string
}): Promise<ApiResponse<Park>> {
const queryParams: Record<string, string> = {}
if (params?.page) queryParams.page = params.page.toString()
if (params?.search) queryParams.search = params.search
if (params?.ordering) queryParams.ordering = params.ordering
return this.client.get<ApiResponse<Park>>('/api/parks/', queryParams)
}
/**
* Get a single park by slug
*/
async getPark(slug: string): Promise<Park> {
return this.client.get<Park>(`/api/parks/${slug}/`)
}
/**
* Search parks
*/
async searchParks(query: string): Promise<SearchResponse<Park>> {
return this.client.get<SearchResponse<Park>>('/api/parks/search/', { q: query })
}
/**
* Get rides for a specific park
*/
async getParkRides(parkSlug: string): Promise<SearchResponse<Ride>> {
return this.client.get<SearchResponse<Ride>>(`/api/parks/${parkSlug}/rides/`)
}
/**
* Get recently changed parks
*/
async getRecentChanges(days?: number): Promise<{
count: number
days: number
parks: Park[]
}> {
const params: Record<string, string> = {}
if (days) params.days = days.toString()
return this.client.get<{
count: number
days: number
parks: Park[]
}>('/api/v1/parks/recent_changes/', params)
}
/**
* Get recently opened parks
*/
async getRecentOpenings(days?: number): Promise<{
count: number
days: number
parks: Park[]
}> {
const params: Record<string, string> = {}
if (days) params.days = days.toString()
return this.client.get<{
count: number
days: number
parks: Park[]
}>('/api/v1/parks/recent_openings/', params)
}
/**
* Get recently closed parks
*/
async getRecentClosures(days?: number): Promise<{
count: number
days: number
parks: Park[]
}> {
const params: Record<string, string> = {}
if (days) params.days = days.toString()
return this.client.get<{
count: number
days: number
parks: Park[]
}>('/api/v1/parks/recent_closures/', params)
}
/**
* Get parks with recent name changes
*/
async getRecentNameChanges(days?: number): Promise<{
count: number
days: number
parks: Park[]
}> {
const params: Record<string, string> = {}
if (days) params.days = days.toString()
return this.client.get<{
count: number
days: number
parks: Park[]
}>('/api/v1/parks/recent_name_changes/', params)
}
}
/**
* Rides API service
*/
export class RidesApi {
private client: ApiClient
constructor(client: ApiClient = new ApiClient()) {
this.client = client
}
/**
* Get all rides with pagination
*/
async getRides(params?: {
page?: number
search?: string
ordering?: string
}): Promise<ApiResponse<Ride>> {
const queryParams: Record<string, string> = {}
if (params?.page) queryParams.page = params.page.toString()
if (params?.search) queryParams.search = params.search
if (params?.ordering) queryParams.ordering = params.ordering
return this.client.get<ApiResponse<Ride>>('/api/rides/', queryParams)
}
/**
* Get a single ride by park and ride slug
*/
async getRide(parkSlug: string, rideSlug: string): Promise<Ride> {
return this.client.get<Ride>(`/api/rides/${parkSlug}/${rideSlug}/`)
}
/**
* Search rides
*/
async searchRides(query: string): Promise<SearchResponse<Ride>> {
return this.client.get<SearchResponse<Ride>>('/api/rides/search/', { q: query })
}
/**
* Get rides by park
*/
async getRidesByPark(parkSlug: string): Promise<SearchResponse<Ride>> {
return this.client.get<SearchResponse<Ride>>(`/api/rides/by-park/${parkSlug}/`)
}
/**
* Get history for a specific ride (convenience method)
*/
async getRideHistory(
parkSlug: string,
rideSlug: string,
params?: HistoryParams
): Promise<HistoryEvent[]> {
const historyApi = new HistoryApi(this.client)
return historyApi.getRideHistory(parkSlug, rideSlug, params)
}
/**
* Get complete ride history with current state (convenience method)
*/
async getRideHistoryDetail(parkSlug: string, rideSlug: string): Promise<RideHistoryResponse> {
const historyApi = new HistoryApi(this.client)
return historyApi.getRideHistoryDetail(parkSlug, rideSlug)
}
/**
* Get recently changed rides
*/
async getRecentChanges(days?: number): Promise<{
count: number
days: number
rides: Ride[]
}> {
const params: Record<string, string> = {}
if (days) params.days = days.toString()
return this.client.get<{
count: number
days: number
rides: Ride[]
}>('/api/v1/rides/recent_changes/', params)
}
/**
* Get recently opened rides
*/
async getRecentOpenings(days?: number): Promise<{
count: number
days: number
rides: Ride[]
}> {
const params: Record<string, string> = {}
if (days) params.days = days.toString()
return this.client.get<{
count: number
days: number
rides: Ride[]
}>('/api/v1/rides/recent_openings/', params)
}
/**
* Get recently closed rides
*/
async getRecentClosures(days?: number): Promise<{
count: number
days: number
rides: Ride[]
}> {
const params: Record<string, string> = {}
if (days) params.days = days.toString()
return this.client.get<{
count: number
days: number
rides: Ride[]
}>('/api/v1/rides/recent_closures/', params)
}
/**
* Get rides with recent name changes
*/
async getRecentNameChanges(days?: number): Promise<{
count: number
days: number
rides: Ride[]
}> {
const params: Record<string, string> = {}
if (days) params.days = days.toString()
return this.client.get<{
count: number
days: number
rides: Ride[]
}>('/api/v1/rides/recent_name_changes/', params)
}
/**
* Get rides that have been relocated recently
*/
async getRecentRelocations(days?: number): Promise<{
count: number
days: number
rides: Ride[]
}> {
const params: Record<string, string> = {}
if (days) params.days = days.toString()
return this.client.get<{
count: number
days: number
rides: Ride[]
}>('/api/v1/rides/recent_relocations/', params)
}
}
/**
* Authentication API service
*/
export class AuthApi {
private client: ApiClient
constructor(client: ApiClient = new ApiClient()) {
this.client = client
}
/**
* Login with username/email and password
*/
async login(credentials: LoginCredentials): Promise<AuthResponse> {
const response = await this.client.post<AuthResponse>('/api/accounts/login/', credentials)
if (response.token) {
this.client.setAuthToken(response.token)
}
return response
}
/**
* Logout user
*/
async logout(): Promise<void> {
await this.client.post<void>('/api/auth/logout/')
this.client.setAuthToken(null)
}
/**
* Register new user
*/
async signup(credentials: SignupCredentials): Promise<AuthResponse> {
const response = await this.client.post<AuthResponse>('/api/auth/signup/', credentials)
if (response.token) {
this.client.setAuthToken(response.token)
}
return response
}
/**
* Get current user info
*/
async getCurrentUser(): Promise<User> {
return this.client.get<User>('/api/auth/user/')
}
/**
* Request password reset
*/
async requestPasswordReset(data: PasswordResetRequest): Promise<{ detail: string }> {
return this.client.post<{ detail: string }>('/api/auth/password/reset/', data)
}
/**
* Change password
*/
async changePassword(data: PasswordChangeRequest): Promise<{ detail: string }> {
return this.client.post<{ detail: string }>('/api/auth/password/change/', data)
}
/**
* Get available social auth providers
*/
async getSocialProviders(): Promise<SocialAuthProvider[]> {
return this.client.get<SocialAuthProvider[]>('/api/auth/providers/')
}
/**
* Check if user is authenticated
*/
isAuthenticated(): boolean {
return !!this.client.getAuthToken()
}
}
/**
* History API service for tracking changes across all models
*/
export class HistoryApi {
private client: ApiClient
constructor(client: ApiClient = new ApiClient()) {
this.client = client
}
/**
* Get unified history timeline across all models
*/
async getUnifiedTimeline(params?: HistoryParams): Promise<UnifiedHistoryTimeline> {
const queryParams: Record<string, string> = {}
if (params?.limit) queryParams.limit = params.limit.toString()
if (params?.offset) queryParams.offset = params.offset.toString()
if (params?.event_type) queryParams.event_type = params.event_type
if (params?.start_date) queryParams.start_date = params.start_date
if (params?.end_date) queryParams.end_date = params.end_date
if (params?.model_type) queryParams.model_type = params.model_type
if (params?.significance) queryParams.significance = params.significance
return this.client.get<UnifiedHistoryTimeline>('/api/v1/history/timeline/', queryParams)
}
/**
* Get history events for a specific park
*/
async getParkHistory(parkSlug: string, params?: HistoryParams): Promise<HistoryEvent[]> {
const queryParams: Record<string, string> = {}
if (params?.limit) queryParams.limit = params.limit.toString()
if (params?.offset) queryParams.offset = params.offset.toString()
if (params?.event_type) queryParams.event_type = params.event_type
if (params?.start_date) queryParams.start_date = params.start_date
if (params?.end_date) queryParams.end_date = params.end_date
return this.client.get<HistoryEvent[]>(`/api/v1/parks/${parkSlug}/history/`, queryParams)
}
/**
* Get complete park history with current state and summary
*/
async getParkHistoryDetail(parkSlug: string): Promise<ParkHistoryResponse> {
return this.client.get<ParkHistoryResponse>(`/api/v1/parks/${parkSlug}/history/detail/`)
}
/**
* Get history events for a specific ride
*/
async getRideHistory(
parkSlug: string,
rideSlug: string,
params?: HistoryParams
): Promise<HistoryEvent[]> {
const queryParams: Record<string, string> = {}
if (params?.limit) queryParams.limit = params.limit.toString()
if (params?.offset) queryParams.offset = params.offset.toString()
if (params?.event_type) queryParams.event_type = params.event_type
if (params?.start_date) queryParams.start_date = params.start_date
if (params?.end_date) queryParams.end_date = params.end_date
return this.client.get<HistoryEvent[]>(
`/api/v1/parks/${parkSlug}/rides/${rideSlug}/history/`,
queryParams
)
}
/**
* Get complete ride history with current state and summary
*/
async getRideHistoryDetail(parkSlug: string, rideSlug: string): Promise<RideHistoryResponse> {
return this.client.get<RideHistoryResponse>(
`/api/v1/parks/${parkSlug}/rides/${rideSlug}/history/detail/`
)
}
/**
* Get recent changes across all models (convenience method)
*/
async getRecentChanges(limit: number = 50): Promise<UnifiedHistoryEvent[]> {
const timeline = await this.getUnifiedTimeline({ limit })
return timeline.events
}
/**
* Get history for a specific model type
*/
async getModelHistory(
modelType: 'park' | 'ride' | 'company' | 'user',
params?: HistoryParams
): Promise<UnifiedHistoryEvent[]> {
const timeline = await this.getUnifiedTimeline({
...params,
model_type: modelType,
})
return timeline.events
}
/**
* Get significant changes only (major events)
*/
async getSignificantChanges(params?: HistoryParams): Promise<UnifiedHistoryEvent[]> {
const timeline = await this.getUnifiedTimeline({
...params,
significance: 'major',
})
return timeline.events
}
/**
* Search history events by date range
*/
async getHistoryByDateRange(
startDate: string,
endDate: string,
params?: Omit<HistoryParams, 'start_date' | 'end_date'>
): Promise<UnifiedHistoryEvent[]> {
const timeline = await this.getUnifiedTimeline({
...params,
start_date: startDate,
end_date: endDate,
})
return timeline.events
}
}
/**
* Main API service that combines all endpoints
*/
export class ThrillWikiApi {
public parks: ParksApi
public rides: RidesApi
public auth: AuthApi
public history: HistoryApi
private client: ApiClient
constructor() {
this.client = new ApiClient()
this.parks = new ParksApi(this.client)
this.rides = new RidesApi(this.client)
this.auth = new AuthApi(this.client)
this.history = new HistoryApi(this.client)
}
/**
* Global search across parks and rides
*/
async globalSearch(query: string): Promise<{
parks: SearchResponse<Park>
rides: SearchResponse<Ride>
}> {
const [parksResult, ridesResult] = await Promise.all([
this.parks.searchParks(query),
this.rides.searchRides(query),
])
return {
parks: parksResult,
rides: ridesResult,
}
}
/**
* Get API base URL for use in other parts of the app
*/
getBaseUrl(): string {
return this.client['baseUrl']
}
/**
* Health check endpoint
*/
async healthCheck(): Promise<{ status: string; timestamp: string }> {
return this.client.get<{ status: string; timestamp: string }>('/health/')
}
}
// Create and export singleton instance
export const api = new ThrillWikiApi()
// Export individual services for direct use
export const parksApi = api.parks
export const ridesApi = api.rides
export const authApi = api.auth
export const historyApi = api.history
// Export types for use in components
export type {
ApiResponse,
SearchResponse,
HistoryEvent,
UnifiedHistoryEvent,
HistorySummary,
ParkHistoryResponse,
RideHistoryResponse,
UnifiedHistoryTimeline,
HistoryParams
}

View File

@@ -0,0 +1,12 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})

View File

@@ -0,0 +1,72 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { api } from '@/services/api'
import type { Park } from '@/types'
export const useParksStore = defineStore('parks', () => {
const parks = ref<Park[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
// Computed getters
const openParks = computed(() => parks.value.filter((park) => park.status === 'open'))
const seasonalParks = computed(() => parks.value.filter((park) => park.status === 'seasonal'))
const totalParks = computed(() => parks.value.length)
// Actions
const fetchParks = async () => {
isLoading.value = true
error.value = null
try {
const response = await api.parks.getParks()
parks.value = response.results
} catch (err) {
error.value = 'Failed to fetch parks'
console.error('Error fetching parks:', err)
} finally {
isLoading.value = false
}
}
const getParkBySlug = async (slug: string): Promise<Park | null> => {
try {
return await api.parks.getPark(slug)
} catch (err) {
console.error('Error fetching park by slug:', err)
return null
}
}
const searchParks = async (query: string): Promise<Park[]> => {
if (!query.trim()) return parks.value
try {
const response = await api.parks.searchParks(query)
return response.results
} catch (err) {
console.error('Error searching parks:', err)
return []
}
}
return {
// State
parks,
isLoading,
error,
// Getters
openParks,
seasonalParks,
totalParks,
// Actions
fetchParks,
getParkBySlug,
searchParks,
}
})

View File

@@ -0,0 +1,115 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { api } from '@/services/api'
import type { Ride } from '@/types'
export const useRidesStore = defineStore('rides', () => {
const rides = ref<Ride[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
// Computed getters
const operatingRides = computed(() => rides.value.filter((ride) => ride.status === 'operating'))
const ridesByCategory = computed(() => {
const categories: Record<string, Ride[]> = {}
rides.value.forEach((ride) => {
if (!categories[ride.category]) {
categories[ride.category] = []
}
categories[ride.category].push(ride)
})
return categories
})
const ridesByStatus = computed(() => {
const statuses: Record<string, Ride[]> = {}
rides.value.forEach((ride) => {
if (!statuses[ride.status]) {
statuses[ride.status] = []
}
statuses[ride.status].push(ride)
})
return statuses
})
const totalRides = computed(() => rides.value.length)
// Actions
const fetchRides = async () => {
isLoading.value = true
error.value = null
try {
const response = await api.rides.getRides()
rides.value = response.results
} catch (err) {
error.value = 'Failed to fetch rides'
console.error('Error fetching rides:', err)
} finally {
isLoading.value = false
}
}
const fetchRidesByPark = async (parkSlug: string): Promise<Ride[]> => {
isLoading.value = true
error.value = null
try {
const response = await api.parks.getParkRides(parkSlug)
return response.results
} catch (err) {
error.value = 'Failed to fetch park rides'
console.error('Error fetching park rides:', err)
return []
} finally {
isLoading.value = false
}
}
const getRideBySlug = async (parkSlug: string, rideSlug: string): Promise<Ride | null> => {
try {
return await api.rides.getRide(parkSlug, rideSlug)
} catch (err) {
console.error('Error fetching ride by slug:', err)
return null
}
}
const searchRides = async (query: string): Promise<Ride[]> => {
if (!query.trim()) return rides.value
try {
const response = await api.rides.searchRides(query)
return response.results
} catch (err) {
console.error('Error searching rides:', err)
return []
}
}
const getRidesByParkSlug = (parkSlug: string): Ride[] => {
return rides.value.filter((ride) => ride.parkSlug === parkSlug)
}
return {
// State
rides,
isLoading,
error,
// Getters
operatingRides,
ridesByCategory,
ridesByStatus,
totalRides,
// Actions
fetchRides,
fetchRidesByPark,
getRideBySlug,
searchRides,
getRidesByParkSlug,
}
})

38
frontend/src/style.css Normal file
View File

@@ -0,0 +1,38 @@
@import 'tailwindcss';
/* Custom base styles */
@layer base {
html {
@apply scroll-smooth;
}
body {
@apply bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 transition-colors duration-200;
}
}
/* Custom component styles */
@layer components {
.btn-primary {
@apply bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200;
}
.btn-secondary {
@apply bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 font-medium py-2 px-4 rounded-lg transition-colors duration-200;
}
.card {
@apply bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm transition-colors duration-200;
}
.input {
@apply w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors duration-200;
}
}
/* Custom utilities */
@layer utilities {
.text-balance {
text-wrap: balance;
}
}

Some files were not shown because too many files have changed in this diff Show More