mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 08:31:08 -05:00
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:
33
.clinerules
33
.clinerules
@@ -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
|
||||
- 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.
|
||||
314
README.md
314
README.md
@@ -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
|
||||
|
||||
@@ -32,41 +47,63 @@ thrillwiki-monorepo/
|
||||
```bash
|
||||
# Install frontend dependencies
|
||||
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
|
||||
pnpm run dev
|
||||
|
||||
# Or start individually
|
||||
pnpm run dev:frontend # Vue.js on :3000
|
||||
pnpm run dev:backend # Django on :8000
|
||||
# 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
|
||||
```
|
||||
|
||||
## 📁 Project Structure
|
||||
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 :5174
|
||||
pnpm run dev:backend # Django on :8000
|
||||
```
|
||||
|
||||
## 📁 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**
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()}')
|
||||
)
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
246
backend/apps/accounts/serializers.py
Normal file
246
backend/apps/accounts/serializers.py
Normal 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()
|
||||
5
backend/apps/api/__init__.py
Normal file
5
backend/apps/api/__init__.py
Normal 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
17
backend/apps/api/apps.py
Normal 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
|
||||
6
backend/apps/api/v1/__init__.py
Normal file
6
backend/apps/api/v1/__init__.py
Normal 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.
|
||||
"""
|
||||
334
backend/apps/api/v1/schema.py
Normal file
334
backend/apps/api/v1/schema.py
Normal 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}")
|
||||
2179
backend/apps/api/v1/serializers.py
Normal file
2179
backend/apps/api/v1/serializers.py
Normal file
File diff suppressed because it is too large
Load Diff
142
backend/apps/api/v1/urls.py
Normal file
142
backend/apps/api/v1/urls.py
Normal 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)),
|
||||
]
|
||||
2910
backend/apps/api/v1/viewsets.py
Normal file
2910
backend/apps/api/v1/viewsets.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 = (
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
if history_entry:
|
||||
return cls.objects.get(id=history_entry.pgh_obj_id), True
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
# Parks API module
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
# Rides API module
|
||||
@@ -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()
|
||||
@@ -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)),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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.
|
||||
|
||||
@@ -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"""
|
||||
|
||||
|
||||
@@ -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
2988
backend/schema.yml
Normal file
File diff suppressed because it is too large
Load Diff
45
backend/setup_social_providers.py
Normal file
45
backend/setup_social_providers.py
Normal 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()}")
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
Binary file not shown.
BIN
context_portal/conport_vector_data/chroma.sqlite3
Normal file
BIN
context_portal/conport_vector_data/chroma.sqlite3
Normal file
Binary file not shown.
Binary file not shown.
8
frontend/.editorconfig
Normal file
8
frontend/.editorconfig
Normal 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
|
||||
6
frontend/.env.development
Normal file
6
frontend/.env.development
Normal 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
6
frontend/.env.production
Normal 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
6
frontend/.env.staging
Normal 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
1
frontend/.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
33
frontend/.gitignore
vendored
Normal file
33
frontend/.gitignore
vendored
Normal 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/
|
||||
6
frontend/.prettierrc.json
Normal file
6
frontend/.prettierrc.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
@@ -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
|
||||
│ ├── views/ # Page components
|
||||
│ ├── router/ # Vue Router setup
|
||||
│ ├── 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
|
||||
│ ├── 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
|
||||
│ │ ├── Home.vue # Landing page
|
||||
│ │ ├── SearchResults.vue # Search results page
|
||||
│ │ ├── parks/ # Park-related pages
|
||||
│ │ └── rides/ # Ride-related pages
|
||||
│ ├── stores/ # Pinia state management
|
||||
│ ├── 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)
|
||||
|
||||
const toggleTheme = () => {
|
||||
isDark.value = !isDark.value
|
||||
document.documentElement.classList.toggle('dark')
|
||||
}
|
||||
|
||||
return { isDark, toggleTheme }
|
||||
}
|
||||
```
|
||||
## 📁 Project Structure Details
|
||||
|
||||
## 🔌 API Integration
|
||||
### Components Architecture
|
||||
|
||||
### Service Layer
|
||||
#### UI Components (`src/components/ui/`)
|
||||
Base component library following shadcn-vue patterns:
|
||||
|
||||
```typescript
|
||||
// services/api.ts
|
||||
class ApiService {
|
||||
private client = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
// API methods
|
||||
getParks(params?: ParkFilters) {
|
||||
return this.client.get('/parks/', { params })
|
||||
}
|
||||
}
|
||||
```
|
||||
- **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
|
||||
|
||||
### Error Handling
|
||||
#### Layout Components (`src/components/layout/`)
|
||||
Application layout and navigation:
|
||||
|
||||
- Global error interceptors
|
||||
- User-friendly error messages
|
||||
- Retry mechanisms for failed requests
|
||||
- Offline support indicators
|
||||
- **Navbar** - Main navigation with responsive design
|
||||
- **ThemeController** - Dark/light mode toggle
|
||||
- **Footer** - Site footer with links
|
||||
|
||||
## 🧪 Testing
|
||||
#### Specialized Components
|
||||
- **State Layer** - Material Design ripple effects
|
||||
- **Icon** - Lucide React icon wrapper
|
||||
- **Button variants** - Different button styles
|
||||
|
||||
### Test Structure
|
||||
### Views Structure
|
||||
|
||||
```bash
|
||||
tests/
|
||||
├── unit/ # Unit tests
|
||||
├── e2e/ # End-to-end tests
|
||||
└── __mocks__/ # Mock files
|
||||
```
|
||||
#### 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
|
||||
|
||||
### Running Tests
|
||||
### State Management
|
||||
|
||||
```bash
|
||||
# Unit tests
|
||||
pnpm run test
|
||||
#### 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
|
||||
|
||||
# E2E tests
|
||||
pnpm run test:e2e
|
||||
### API Integration
|
||||
|
||||
# Watch mode
|
||||
pnpm run test:watch
|
||||
```
|
||||
#### 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
|
||||
|
||||
### Testing Tools
|
||||
### Type Definitions
|
||||
|
||||
- **Vitest** - Unit testing framework
|
||||
- **Vue Test Utils** - Vue component testing
|
||||
- **Playwright** - End-to-end testing
|
||||
#### 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
|
||||
|
||||
## 📱 Progressive Web App
|
||||
## 🎨 Design System
|
||||
|
||||
PWA features:
|
||||
### 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
|
||||
|
||||
- **Service Worker** for offline functionality
|
||||
- **App Manifest** for installation
|
||||
- **Push Notifications** for updates
|
||||
- **Background Sync** for data synchronization
|
||||
### Typography
|
||||
- **Inter font family** for modern appearance
|
||||
- **Responsive text scales** for all screen sizes
|
||||
- **Consistent line heights** for readability
|
||||
|
||||
## 🔧 Build & Deployment
|
||||
### Component Variants
|
||||
- **Button variants** - Primary, secondary, outline, ghost
|
||||
- **Card variants** - Default, elevated, outlined
|
||||
- **Input variants** - Default, error, success
|
||||
|
||||
### Development Build
|
||||
### Dark Mode
|
||||
- **Automatic detection** of system preference
|
||||
- **Manual toggle** in theme controller
|
||||
- **Smooth transitions** between themes
|
||||
- **CSS custom properties** for dynamic theming
|
||||
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
## 🧪 Testing Strategy
|
||||
|
||||
### Production Build
|
||||
### 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
|
||||
|
||||
```bash
|
||||
pnpm run 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
|
||||
|
||||
### 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
13
frontend/components.json
Normal 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"
|
||||
}
|
||||
4
frontend/e2e/tsconfig.json
Normal file
4
frontend/e2e/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"include": ["./**/*"]
|
||||
}
|
||||
8
frontend/e2e/vue.spec.ts
Normal file
8
frontend/e2e/vue.spec.ts
Normal 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
1
frontend/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
36
frontend/eslint.config.ts
Normal file
36
frontend/eslint.config.ts
Normal 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,
|
||||
)
|
||||
@@ -1,14 +1,13 @@
|
||||
<!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>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
110
frontend/playwright.config.ts
Normal file
110
frontend/playwright.config.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal 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
459
frontend/src/App.vue
Normal 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>
|
||||
11
frontend/src/__tests__/App.spec.ts
Normal file
11
frontend/src/__tests__/App.spec.ts
Normal 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!')
|
||||
})
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
81
frontend/src/components/auth/AuthManager.vue
Normal file
81
frontend/src/components/auth/AuthManager.vue
Normal 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>
|
||||
103
frontend/src/components/auth/AuthModal.vue
Normal file
103
frontend/src/components/auth/AuthModal.vue
Normal 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>
|
||||
175
frontend/src/components/auth/ForgotPasswordModal.vue
Normal file
175
frontend/src/components/auth/ForgotPasswordModal.vue
Normal 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>
|
||||
237
frontend/src/components/auth/LoginModal.vue
Normal file
237
frontend/src/components/auth/LoginModal.vue
Normal 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>
|
||||
335
frontend/src/components/auth/SignupModal.vue
Normal file
335
frontend/src/components/auth/SignupModal.vue
Normal 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>
|
||||
87
frontend/src/components/button/button.css
Normal file
87
frontend/src/components/button/button.css
Normal 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);
|
||||
}
|
||||
137
frontend/src/components/button/index.tsx
Normal file
137
frontend/src/components/button/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
238
frontend/src/components/icon/icon.css
Normal file
238
frontend/src/components/icon/icon.css
Normal 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)));
|
||||
}
|
||||
38
frontend/src/components/icon/index.tsx
Normal file
38
frontend/src/components/icon/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
7
frontend/src/components/icons/DiscordIcon.vue
Normal file
7
frontend/src/components/icons/DiscordIcon.vue
Normal 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>
|
||||
20
frontend/src/components/icons/GoogleIcon.vue
Normal file
20
frontend/src/components/icons/GoogleIcon.vue
Normal 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>
|
||||
259
frontend/src/components/layout/Navbar.vue
Normal file
259
frontend/src/components/layout/Navbar.vue
Normal 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>
|
||||
363
frontend/src/components/layout/ThemeController.vue
Normal file
363
frontend/src/components/layout/ThemeController.vue
Normal 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>
|
||||
19
frontend/src/components/state-layer/index.tsx
Normal file
19
frontend/src/components/state-layer/index.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
46
frontend/src/components/state-layer/state-layer.css
Normal file
46
frontend/src/components/state-layer/state-layer.css
Normal 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;
|
||||
}
|
||||
123
frontend/src/components/ui/Badge.vue
Normal file
123
frontend/src/components/ui/Badge.vue
Normal 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>
|
||||
172
frontend/src/components/ui/Button.vue
Normal file
172
frontend/src/components/ui/Button.vue
Normal 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>
|
||||
206
frontend/src/components/ui/Card.vue
Normal file
206
frontend/src/components/ui/Card.vue
Normal 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>
|
||||
297
frontend/src/components/ui/SearchInput.vue
Normal file
297
frontend/src/components/ui/SearchInput.vue
Normal 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>
|
||||
82
frontend/src/components/ui/index.ts
Normal file
82
frontend/src/components/ui/index.ts
Normal 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
|
||||
}
|
||||
201
frontend/src/composables/useAuth.ts
Normal file
201
frontend/src/composables/useAuth.ts
Normal 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
|
||||
@@ -1,13 +1,15 @@
|
||||
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)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
app.mount('#app')
|
||||
|
||||
104
frontend/src/router/index.ts
Normal file
104
frontend/src/router/index.ts
Normal 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
|
||||
800
frontend/src/services/api.ts
Normal file
800
frontend/src/services/api.ts
Normal 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
|
||||
}
|
||||
12
frontend/src/stores/counter.ts
Normal file
12
frontend/src/stores/counter.ts
Normal 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 }
|
||||
})
|
||||
72
frontend/src/stores/parks.ts
Normal file
72
frontend/src/stores/parks.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
115
frontend/src/stores/rides.ts
Normal file
115
frontend/src/stores/rides.ts
Normal 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
38
frontend/src/style.css
Normal 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
Reference in New Issue
Block a user