mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 07:51:09 -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:
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;
|
||||
}
|
||||
}
|
||||
181
frontend/src/types/index.ts
Normal file
181
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Type definitions for ThrillWiki application
|
||||
*/
|
||||
|
||||
// Park related types
|
||||
export interface Park {
|
||||
id: number
|
||||
slug: string
|
||||
name: string
|
||||
description: string
|
||||
location: string
|
||||
country: string
|
||||
openingYear: number | null
|
||||
status: 'open' | 'closed' | 'seasonal' | 'construction'
|
||||
operator: string
|
||||
propertyOwner?: string
|
||||
website?: string
|
||||
area?: number
|
||||
rideCount: number
|
||||
created: string
|
||||
updated: string
|
||||
}
|
||||
|
||||
// Ride related types
|
||||
export interface Ride {
|
||||
id: number
|
||||
slug: string
|
||||
name: string
|
||||
description: string
|
||||
category:
|
||||
| 'roller_coaster'
|
||||
| 'water_ride'
|
||||
| 'dark_ride'
|
||||
| 'thrill_ride'
|
||||
| 'family_ride'
|
||||
| 'kiddie_ride'
|
||||
| 'transport'
|
||||
status: 'operating' | 'closed' | 'under_construction' | 'seasonal' | 'sbno'
|
||||
openingDate: string | null
|
||||
closingDate: string | null
|
||||
manufacturer?: string
|
||||
designer?: string
|
||||
height?: number
|
||||
length?: number
|
||||
speed?: number
|
||||
inversions?: number
|
||||
duration?: number
|
||||
capacity?: number
|
||||
parkId: number
|
||||
parkName: string
|
||||
parkSlug: string
|
||||
created: string
|
||||
updated: string
|
||||
}
|
||||
|
||||
// Search and filter types
|
||||
export interface SearchFilters {
|
||||
query?: string
|
||||
category?: string
|
||||
status?: string
|
||||
country?: string
|
||||
manufacturer?: string
|
||||
designer?: string
|
||||
minHeight?: number
|
||||
maxHeight?: number
|
||||
minSpeed?: number
|
||||
maxSpeed?: number
|
||||
}
|
||||
|
||||
export interface ParkFilters {
|
||||
query?: string
|
||||
status?: string
|
||||
country?: string
|
||||
operator?: string
|
||||
minYear?: number
|
||||
maxYear?: number
|
||||
}
|
||||
|
||||
// API response types
|
||||
export interface ApiResponse<T> {
|
||||
count: number
|
||||
next: string | null
|
||||
previous: string | null
|
||||
results: T[]
|
||||
}
|
||||
|
||||
export interface SearchResponse<T> {
|
||||
results: T[]
|
||||
count: number
|
||||
query: string
|
||||
}
|
||||
|
||||
// Component prop types
|
||||
export interface PaginationInfo {
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
totalItems: number
|
||||
itemsPerPage: number
|
||||
}
|
||||
|
||||
// Form types
|
||||
export interface ContactForm {
|
||||
name: string
|
||||
email: string
|
||||
subject: string
|
||||
message: string
|
||||
}
|
||||
|
||||
// Theme types
|
||||
export interface ThemeConfig {
|
||||
isDark: boolean
|
||||
primaryColor: string
|
||||
accentColor: string
|
||||
}
|
||||
|
||||
// Navigation types
|
||||
export interface NavItem {
|
||||
label: string
|
||||
to: string
|
||||
icon?: string
|
||||
children?: NavItem[]
|
||||
}
|
||||
|
||||
// Error types
|
||||
export interface ApiError {
|
||||
message: string
|
||||
status: number
|
||||
details?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// Authentication types
|
||||
export interface User {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
displayName?: string
|
||||
avatar?: string
|
||||
role?: string
|
||||
isStaff: boolean
|
||||
isActive: boolean
|
||||
dateJoined: string
|
||||
lastLogin?: string
|
||||
}
|
||||
|
||||
export interface LoginCredentials {
|
||||
username: string
|
||||
password: string
|
||||
remember?: boolean
|
||||
}
|
||||
|
||||
export interface SignupCredentials {
|
||||
username: string
|
||||
email: string
|
||||
password1: string
|
||||
password2: string
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
user: User
|
||||
token?: string
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
export interface PasswordResetRequest {
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface PasswordChangeRequest {
|
||||
oldPassword: string
|
||||
newPassword1: string
|
||||
newPassword2: string
|
||||
}
|
||||
|
||||
export interface SocialAuthProvider {
|
||||
id: string
|
||||
name: string
|
||||
iconUrl?: string
|
||||
authUrl: string
|
||||
}
|
||||
127
frontend/src/views/Error.vue
Normal file
127
frontend/src/views/Error.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-gray-50 dark:bg-gray-900 min-h-screen flex items-center justify-center py-8 md:py-12"
|
||||
>
|
||||
<div class="max-w-md w-full px-4">
|
||||
<div class="text-center">
|
||||
<!-- Error Icon -->
|
||||
<div
|
||||
class="mx-auto flex items-center justify-center h-32 w-32 rounded-full bg-red-100 dark:bg-red-900/20 mb-8"
|
||||
>
|
||||
<svg
|
||||
class="h-16 w-16 text-red-600 dark:text-red-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{{ errorCode || 'Error' }}
|
||||
</h1>
|
||||
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-300 mb-4">
|
||||
{{ errorTitle || 'Something went wrong' }}
|
||||
</h2>
|
||||
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-8 max-w-sm mx-auto">
|
||||
{{
|
||||
errorMessage ||
|
||||
'An unexpected error occurred. Please try again later or contact support if the problem persists.'
|
||||
}}
|
||||
</p>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="space-y-3 sm:space-y-0 sm:space-x-3 sm:flex sm:justify-center">
|
||||
<button
|
||||
@click="goBack"
|
||||
class="w-full sm:w-auto inline-flex items-center justify-center px-6 py-3 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 transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
Go Back
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="tryAgain"
|
||||
class="w-full sm:w-auto inline-flex items-center justify-center px-6 py-3 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 transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Additional Actions -->
|
||||
<div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="space-y-2 text-sm">
|
||||
<router-link
|
||||
to="/"
|
||||
class="block text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300 hover:underline"
|
||||
>
|
||||
Return to Home
|
||||
</router-link>
|
||||
<a
|
||||
href="mailto:support@thrillwiki.com"
|
||||
class="block text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:underline"
|
||||
>
|
||||
Contact Support
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
|
||||
interface Props {
|
||||
errorCode?: string | number
|
||||
errorTitle?: string
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const goBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
router.go(-1)
|
||||
} else {
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
|
||||
const tryAgain = () => {
|
||||
// Try to reload the current route or go to a safe route
|
||||
if (route.path === '/error') {
|
||||
router.push('/')
|
||||
} else {
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
553
frontend/src/views/Home.vue
Normal file
553
frontend/src/views/Home.vue
Normal file
@@ -0,0 +1,553 @@
|
||||
<template>
|
||||
<div class="bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<!-- Hero Section -->
|
||||
<section
|
||||
class="bg-gradient-to-br from-blue-600 via-purple-600 to-purple-700 text-white relative overflow-hidden"
|
||||
>
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-24 relative z-10">
|
||||
<div class="text-center max-w-4xl mx-auto">
|
||||
<h1 class="text-4xl md:text-6xl font-bold mb-6">Discover Your Next Thrill</h1>
|
||||
<p class="text-xl md:text-2xl mb-8 text-purple-100">
|
||||
Search through thousands of amusement rides and parks in an expansive community database
|
||||
</p>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="max-w-2xl mx-auto mb-16">
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<svg
|
||||
class="h-6 w-6 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 for amusement rides, parks, manufacturers..."
|
||||
class="block w-full pl-12 pr-20 py-4 text-lg border-0 rounded-lg bg-white text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-white focus:ring-opacity-50"
|
||||
v-model="heroSearchQuery"
|
||||
@keyup.enter="handleHeroSearch"
|
||||
/>
|
||||
<button
|
||||
@click="handleHeroSearch"
|
||||
class="absolute inset-y-0 right-0 px-6 bg-gray-900 hover:bg-gray-800 rounded-r-lg text-white font-medium transition-colors"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-8 max-w-2xl mx-auto">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl md:text-4xl font-bold mb-2">{{ stats.parks }}</div>
|
||||
<div class="text-purple-200 text-sm">Parks</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl md:text-4xl font-bold mb-2">{{ stats.rides }}</div>
|
||||
<div class="text-purple-200 text-sm">Rides</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl md:text-4xl font-bold mb-2">{{ stats.reviews }}</div>
|
||||
<div class="text-purple-200 text-sm">Reviews</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl md:text-4xl font-bold mb-2">{{ stats.photos }}</div>
|
||||
<div class="text-purple-200 text-sm">Photos</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Background decoration -->
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-br from-transparent via-purple-600/20 to-purple-900/40"
|
||||
></div>
|
||||
</section>
|
||||
|
||||
<!-- What's Trending Section -->
|
||||
<section class="py-16 bg-white dark:bg-gray-800">
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-12">
|
||||
<div class="flex items-center justify-center mb-4">
|
||||
<svg class="h-6 w-6 text-orange-500 mr-2" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2L13.09 8.26L22 9L13.09 9.74L12 16L10.91 9.74L2 9L10.91 8.26L12 2Z" />
|
||||
</svg>
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white">
|
||||
What's Trending
|
||||
</h2>
|
||||
</div>
|
||||
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||
See what the community is checking out this week
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Trending Tabs -->
|
||||
<div class="flex justify-center mb-8">
|
||||
<div class="flex bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
|
||||
<button
|
||||
v-for="tab in trendingTabs"
|
||||
:key="tab.id"
|
||||
@click="activeTrendingTab = tab.id"
|
||||
:class="[
|
||||
'px-6 py-2 text-sm font-medium rounded-md transition-colors',
|
||||
activeTrendingTab === tab.id
|
||||
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white',
|
||||
]"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trending Content -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div
|
||||
v-for="item in getTrendingContent()"
|
||||
:key="item.id"
|
||||
class="bg-white dark:bg-gray-700 rounded-lg shadow-lg overflow-hidden hover:shadow-xl transition-shadow cursor-pointer group"
|
||||
@click="viewTrendingItem(item)"
|
||||
>
|
||||
<!-- Image placeholder -->
|
||||
<div
|
||||
class="h-48 bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-600 dark:to-gray-700 flex items-center justify-center relative"
|
||||
>
|
||||
<svg
|
||||
class="h-12 w-12 text-gray-400 dark:text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<!-- Trending badge -->
|
||||
<div class="absolute top-3 left-3">
|
||||
<span class="bg-orange-500 text-white text-xs font-medium px-2 py-1 rounded-full">
|
||||
#{{ item.rank }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Rating badge -->
|
||||
<div class="absolute top-3 right-3">
|
||||
<span
|
||||
class="bg-gray-900 bg-opacity-75 text-white text-xs font-medium px-2 py-1 rounded-full flex items-center"
|
||||
>
|
||||
<svg class="h-3 w-3 text-yellow-400 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
|
||||
/>
|
||||
</svg>
|
||||
{{ item.rating }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<h3
|
||||
class="text-lg font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{{ item.name }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
<span class="font-medium">{{ item.location }}</span>
|
||||
<span v-if="item.category"> • {{ item.category }}</span>
|
||||
</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<span
|
||||
class="text-green-600 dark:text-green-400 text-sm font-medium flex items-center"
|
||||
>
|
||||
<svg class="h-4 w-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
|
||||
/>
|
||||
</svg>
|
||||
+{{ item.views_change }}%
|
||||
</span>
|
||||
<span
|
||||
class="text-blue-600 dark:text-blue-400 text-sm font-medium group-hover:underline"
|
||||
>
|
||||
{{ item.views }} views
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- What's New Section -->
|
||||
<section class="py-16 bg-gray-50 dark:bg-gray-900">
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-12">
|
||||
<div class="flex items-center justify-center mb-4">
|
||||
<svg class="h-6 w-6 text-blue-500 mr-2" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2L15.09 8.26L22 9L15.09 9.74L12 16L8.91 9.74L2 9L8.91 8.26L12 2Z" />
|
||||
</svg>
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white">What's New</h2>
|
||||
</div>
|
||||
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||
Stay up to date with the latest additions and upcoming attractions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- New Tabs -->
|
||||
<div class="flex justify-center mb-8">
|
||||
<div class="flex bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
|
||||
<button
|
||||
v-for="tab in newTabs"
|
||||
:key="tab.id"
|
||||
@click="activeNewTab = tab.id"
|
||||
:class="[
|
||||
'px-6 py-2 text-sm font-medium rounded-md transition-colors',
|
||||
activeNewTab === tab.id
|
||||
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white',
|
||||
]"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Content -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
|
||||
<div
|
||||
v-for="item in getNewContent()"
|
||||
:key="item.id"
|
||||
class="bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden hover:shadow-xl transition-shadow cursor-pointer group"
|
||||
@click="viewNewItem(item)"
|
||||
>
|
||||
<!-- Image placeholder -->
|
||||
<div
|
||||
class="h-48 bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-600 dark:to-gray-700 flex items-center justify-center relative"
|
||||
>
|
||||
<svg
|
||||
class="h-12 w-12 text-gray-400 dark:text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<!-- New badge -->
|
||||
<div class="absolute top-3 left-3">
|
||||
<span class="bg-green-500 text-white text-xs font-medium px-2 py-1 rounded-full">
|
||||
New
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<h3
|
||||
class="text-lg font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{{ item.name }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
<span class="font-medium">{{ item.location }}</span>
|
||||
<span v-if="item.category"> • {{ item.category }}</span>
|
||||
</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400 text-sm">
|
||||
Added {{ item.date_added }}
|
||||
</span>
|
||||
<span
|
||||
class="text-blue-600 dark:text-blue-400 text-sm font-medium group-hover:underline"
|
||||
>
|
||||
View All New Additions →
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Join the ThrillWiki Community Section -->
|
||||
<section class="py-16 bg-white dark:bg-gray-800">
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-6">
|
||||
Join the ThrillWiki Community
|
||||
</h2>
|
||||
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-3xl mx-auto mb-8">
|
||||
Share your experiences, contribute to our database, and connect with fellow theme park
|
||||
enthusiasts.
|
||||
</p>
|
||||
<button
|
||||
class="px-8 py-4 bg-gray-900 hover:bg-gray-800 dark:bg-gray-700 dark:hover:bg-gray-600 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Get Started Today
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const heroSearchQuery = ref('')
|
||||
const activeTrendingTab = ref('rides')
|
||||
const activeNewTab = ref('recently-added')
|
||||
|
||||
// Sample data matching the design
|
||||
const stats = ref({
|
||||
parks: 5,
|
||||
rides: 10,
|
||||
reviews: 20,
|
||||
photos: 1,
|
||||
})
|
||||
|
||||
const trendingTabs = [
|
||||
{ id: 'rides', label: 'Trending Rides' },
|
||||
{ id: 'parks', label: 'Trending Parks' },
|
||||
{ id: 'reviews', label: 'Latest Reviews' },
|
||||
]
|
||||
|
||||
const newTabs = [
|
||||
{ id: 'recently-added', label: 'Recently Added' },
|
||||
{ id: 'newly-opened', label: 'Newly Opened' },
|
||||
{ id: 'upcoming', label: 'Upcoming' },
|
||||
]
|
||||
|
||||
const trendingRides = ref([
|
||||
{
|
||||
id: 1,
|
||||
name: 'Steel Vengeance',
|
||||
location: 'Cedar Point',
|
||||
category: 'Hybrid Coaster',
|
||||
rating: 4.9,
|
||||
rank: 1,
|
||||
views: 4820,
|
||||
views_change: 23,
|
||||
slug: 'steel-vengeance',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Kingda Ka',
|
||||
location: 'Six Flags Great Adventure',
|
||||
category: 'Launched Coaster',
|
||||
rating: 4.8,
|
||||
rank: 2,
|
||||
views: 3647,
|
||||
views_change: 18,
|
||||
slug: 'kingda-ka',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Pirates of the Caribbean',
|
||||
location: 'Disneyland',
|
||||
category: 'Dark Ride',
|
||||
rating: 4.7,
|
||||
rank: 3,
|
||||
views: 3156,
|
||||
views_change: 12,
|
||||
slug: 'pirates-of-the-caribbean',
|
||||
},
|
||||
])
|
||||
|
||||
const trendingParks = ref([
|
||||
{
|
||||
id: 1,
|
||||
name: 'Cedar Point',
|
||||
location: 'Sandusky, Ohio',
|
||||
category: 'Amusement Park',
|
||||
rating: 4.8,
|
||||
rank: 1,
|
||||
views: 8920,
|
||||
views_change: 15,
|
||||
slug: 'cedar-point',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Magic Kingdom',
|
||||
location: 'Orlando, Florida',
|
||||
category: 'Theme Park',
|
||||
rating: 4.9,
|
||||
rank: 2,
|
||||
views: 7654,
|
||||
views_change: 12,
|
||||
slug: 'magic-kingdom',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Europa-Park',
|
||||
location: 'Rust, Germany',
|
||||
category: 'Theme Park',
|
||||
rating: 4.7,
|
||||
rank: 3,
|
||||
views: 5432,
|
||||
views_change: 22,
|
||||
slug: 'europa-park',
|
||||
},
|
||||
])
|
||||
|
||||
const latestReviews = ref([
|
||||
{
|
||||
id: 1,
|
||||
name: 'Steel Vengeance Review',
|
||||
location: 'Cedar Point',
|
||||
category: 'Roller Coaster',
|
||||
rating: 5.0,
|
||||
rank: 1,
|
||||
views: 1234,
|
||||
views_change: 45,
|
||||
slug: 'steel-vengeance-review',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Kingda Ka Experience',
|
||||
location: 'Six Flags Great Adventure',
|
||||
category: 'Launch Coaster',
|
||||
rating: 4.8,
|
||||
rank: 2,
|
||||
views: 987,
|
||||
views_change: 32,
|
||||
slug: 'kingda-ka-review',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Pirates Ride Review',
|
||||
location: 'Disneyland',
|
||||
category: 'Dark Ride',
|
||||
rating: 4.6,
|
||||
rank: 3,
|
||||
views: 765,
|
||||
views_change: 28,
|
||||
slug: 'pirates-review',
|
||||
},
|
||||
])
|
||||
|
||||
const recentlyAdded = ref([
|
||||
{
|
||||
id: 1,
|
||||
name: 'Guardians of the Galaxy: Cosmic Rewind',
|
||||
location: 'EPCOT',
|
||||
category: 'Indoor Coaster',
|
||||
date_added: '2024-01-20',
|
||||
slug: 'guardians-cosmic-rewind',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'VelociCoaster',
|
||||
location: "Universal's Islands of Adventure",
|
||||
category: 'Launch Coaster',
|
||||
date_added: '2024-01-18',
|
||||
slug: 'velocicoaster',
|
||||
},
|
||||
])
|
||||
|
||||
const newlyOpened = ref([
|
||||
{
|
||||
id: 1,
|
||||
name: 'TRON Lightcycle / Run',
|
||||
location: 'Magic Kingdom',
|
||||
category: 'Launch Coaster',
|
||||
date_added: '2023-04-04',
|
||||
slug: 'tron-lightcycle-run',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Hagrid's Magical Creatures Motorbike Adventure",
|
||||
location: "Universal's Islands of Adventure",
|
||||
category: 'Story Coaster',
|
||||
date_added: '2019-06-13',
|
||||
slug: 'hagrids-motorbike-adventure',
|
||||
},
|
||||
])
|
||||
|
||||
const upcoming = ref([
|
||||
{
|
||||
id: 1,
|
||||
name: 'Epic Universe',
|
||||
location: 'Universal Orlando',
|
||||
category: 'Theme Park',
|
||||
date_added: 'Opening 2025',
|
||||
slug: 'epic-universe',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'New Fantasyland Expansion',
|
||||
location: 'Magic Kingdom',
|
||||
category: 'Land Expansion',
|
||||
date_added: 'Opening 2026',
|
||||
slug: 'fantasyland-expansion',
|
||||
},
|
||||
])
|
||||
|
||||
// Methods
|
||||
const handleHeroSearch = () => {
|
||||
if (heroSearchQuery.value.trim()) {
|
||||
router.push({
|
||||
name: 'search-results',
|
||||
query: { q: heroSearchQuery.value.trim() },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const getTrendingContent = () => {
|
||||
switch (activeTrendingTab.value) {
|
||||
case 'rides':
|
||||
return trendingRides.value
|
||||
case 'parks':
|
||||
return trendingParks.value
|
||||
case 'reviews':
|
||||
return latestReviews.value
|
||||
default:
|
||||
return trendingRides.value
|
||||
}
|
||||
}
|
||||
|
||||
const getNewContent = () => {
|
||||
switch (activeNewTab.value) {
|
||||
case 'recently-added':
|
||||
return recentlyAdded.value
|
||||
case 'newly-opened':
|
||||
return newlyOpened.value
|
||||
case 'upcoming':
|
||||
return upcoming.value
|
||||
default:
|
||||
return recentlyAdded.value
|
||||
}
|
||||
}
|
||||
|
||||
const viewTrendingItem = (item: any) => {
|
||||
if (activeTrendingTab.value === 'parks') {
|
||||
router.push({ name: 'park-detail', params: { slug: item.slug } })
|
||||
} else if (activeTrendingTab.value === 'rides') {
|
||||
router.push({ name: 'global-ride-detail', params: { rideSlug: item.slug } })
|
||||
}
|
||||
}
|
||||
|
||||
const viewNewItem = (item: any) => {
|
||||
router.push({ name: 'park-detail', params: { slug: item.slug } })
|
||||
}
|
||||
|
||||
// In a real app, you would fetch this data from your Django API
|
||||
onMounted(() => {
|
||||
// TODO: Fetch actual data from Django backend
|
||||
console.log('Home view mounted')
|
||||
})
|
||||
</script>
|
||||
116
frontend/src/views/NotFound.vue
Normal file
116
frontend/src/views/NotFound.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-gray-50 dark:bg-gray-900 min-h-screen flex items-center justify-center py-8 md:py-12"
|
||||
>
|
||||
<div class="max-w-md w-full px-4">
|
||||
<div class="text-center">
|
||||
<!-- Error Icon -->
|
||||
<div
|
||||
class="mx-auto flex items-center justify-center h-32 w-32 rounded-full bg-blue-100 dark:bg-blue-900/20 mb-8"
|
||||
>
|
||||
<svg
|
||||
class="h-16 w-16 text-blue-600 dark:text-blue-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9.172 16.172a4 4 0 015.656 0M9 12h6m-6-4h6m2 5.291A7.962 7.962 0 0112 20c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<h1 class="text-6xl font-bold text-gray-900 dark:text-white mb-4">404</h1>
|
||||
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-300 mb-4">Page Not Found</h2>
|
||||
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-8 max-w-sm mx-auto">
|
||||
The page you're looking for doesn't exist. It might have been moved, deleted, or you
|
||||
entered the wrong URL.
|
||||
</p>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="space-y-3 sm:space-y-0 sm:space-x-3 sm:flex sm:justify-center">
|
||||
<button
|
||||
@click="goBack"
|
||||
class="w-full sm:w-auto inline-flex items-center justify-center px-6 py-3 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 transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
Go Back
|
||||
</button>
|
||||
|
||||
<router-link
|
||||
to="/"
|
||||
class="w-full sm:w-auto inline-flex items-center justify-center px-6 py-3 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 transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
|
||||
/>
|
||||
</svg>
|
||||
Go Home
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Popular Links -->
|
||||
<div class="mt-12 pt-8 border-t border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-4">Popular Pages</h3>
|
||||
<div class="grid grid-cols-2 gap-3 text-sm">
|
||||
<router-link
|
||||
to="/parks"
|
||||
class="text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300 hover:underline"
|
||||
>
|
||||
Browse Parks
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/rides"
|
||||
class="text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300 hover:underline"
|
||||
>
|
||||
Browse Rides
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/search"
|
||||
class="text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300 hover:underline"
|
||||
>
|
||||
Search
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/auth/login"
|
||||
class="text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300 hover:underline"
|
||||
>
|
||||
Sign In
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const goBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
router.go(-1)
|
||||
} else {
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
378
frontend/src/views/SearchResults.vue
Normal file
378
frontend/src/views/SearchResults.vue
Normal file
@@ -0,0 +1,378 @@
|
||||
<template>
|
||||
<div class="bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Search Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl lg:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Search Results
|
||||
</h1>
|
||||
<div v-if="searchQuery" class="text-lg text-gray-600 dark:text-gray-400">
|
||||
Showing results for "<span class="font-semibold text-gray-900 dark:text-white">{{
|
||||
searchQuery
|
||||
}}</span
|
||||
>"
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 mb-8">
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
v-model="currentSearchQuery"
|
||||
@keyup.enter="performSearch"
|
||||
type="text"
|
||||
placeholder="Search parks, rides, or locations..."
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<select
|
||||
v-model="selectedCategory"
|
||||
class="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"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
<option value="parks">Parks</option>
|
||||
<option value="rides">Rides</option>
|
||||
</select>
|
||||
<button
|
||||
@click="performSearch"
|
||||
class="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p class="mt-4 text-gray-600 dark:text-gray-400">Searching...</p>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div v-else-if="!loading && (parks.length > 0 || rides.length > 0)">
|
||||
<!-- Results Summary -->
|
||||
<div
|
||||
class="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center text-blue-800 dark:text-blue-200">
|
||||
<svg class="h-5 w-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
<span class="font-medium">
|
||||
Found {{ totalResults }} result{{ totalResults !== 1 ? 's' : '' }}
|
||||
{{ searchQuery ? `for "${searchQuery}"` : '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Parks Results -->
|
||||
<div v-if="parks.length > 0" class="mb-8">
|
||||
<h2 class="text-2xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<svg
|
||||
class="h-6 w-6 mr-2 text-green-600 dark:text-green-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M3 6a3 3 0 013-3h10a1 1 0 01.8 1.6L14.25 8l2.55 3.4A1 1 0 0116 13H6a1 1 0 00-1 1v3a1 1 0 11-2 0V6z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
Parks ({{ parks.length }})
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div
|
||||
v-for="park in parks"
|
||||
:key="park.id"
|
||||
@click="navigateToPark(park)"
|
||||
class="bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden hover:shadow-xl transition-shadow cursor-pointer group"
|
||||
>
|
||||
<div class="p-6">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<h3
|
||||
class="text-lg font-semibold text-gray-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{{ park.name }}
|
||||
</h3>
|
||||
<span
|
||||
v-if="park.featured"
|
||||
class="px-2 py-1 text-xs font-medium text-yellow-800 bg-yellow-100 dark:bg-yellow-600 dark:text-yellow-50 rounded-full"
|
||||
>
|
||||
Featured
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm mb-3 line-clamp-2">
|
||||
{{ park.description }}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400"> 📍 {{ park.location }} </span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center justify-between">
|
||||
<span class="text-sm text-blue-600 dark:text-blue-400 font-medium">
|
||||
{{ park.rideCount || 0 }} rides
|
||||
</span>
|
||||
<span
|
||||
class="px-2 py-1 text-xs rounded-full"
|
||||
:class="getStatusClasses(park.status)"
|
||||
>
|
||||
{{ park.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rides Results -->
|
||||
<div v-if="rides.length > 0">
|
||||
<h2 class="text-2xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<svg
|
||||
class="h-6 w-6 mr-2 text-purple-600 dark:text-purple-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M12.395 2.553a1 1 0 00-1.45-.385c-.345.23-.614.558-.822.88-.214.33-.403.713-.57 1.116-.334.804-.614 1.768-.84 2.734a31.365 31.365 0 00-.613 3.58 2.64 2.64 0 01-.945-1.067c-.328-.68-.398-1.534-.398-2.654A1 1 0 005.05 6.05 6.981 6.981 0 003 11a7 7 0 1011.95-4.95c-.592-.591-.98-.985-1.348-1.467-.363-.476-.724-1.063-1.207-2.03zM12.12 15.12A3 3 0 017 13s.879.5 2.5.5c0-1 .5-4 1.25-4.5.5 1 .786 1.293 1.371 1.879A2.99 2.99 0 0113 13a2.99 2.99 0 01-.879 2.121z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
Rides ({{ rides.length }})
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div
|
||||
v-for="ride in rides"
|
||||
:key="ride.id"
|
||||
@click="navigateToRide(ride)"
|
||||
class="bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden hover:shadow-xl transition-shadow cursor-pointer group"
|
||||
>
|
||||
<div class="p-6">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<h3
|
||||
class="text-lg font-semibold text-gray-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{{ ride.name }}
|
||||
</h3>
|
||||
<span
|
||||
class="px-2 py-1 text-xs font-medium text-blue-800 bg-blue-100 dark:bg-blue-700 dark:text-blue-50 rounded-full"
|
||||
>
|
||||
{{ ride.category }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm mb-3">at {{ ride.parkName }}</p>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 mb-3 text-sm">
|
||||
<div
|
||||
v-if="ride.height"
|
||||
class="text-center p-2 bg-gray-50 dark:bg-gray-700 rounded"
|
||||
>
|
||||
<div class="font-semibold text-blue-600 dark:text-blue-400">
|
||||
{{ ride.height }}ft
|
||||
</div>
|
||||
<div class="text-gray-500 dark:text-gray-400">Height</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="ride.speed"
|
||||
class="text-center p-2 bg-gray-50 dark:bg-gray-700 rounded"
|
||||
>
|
||||
<div class="font-semibold text-blue-600 dark:text-blue-400">
|
||||
{{ ride.speed }}mph
|
||||
</div>
|
||||
<div class="text-gray-500 dark:text-gray-400">Speed</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end">
|
||||
<span
|
||||
class="px-2 py-1 text-xs rounded-full"
|
||||
:class="getStatusClasses(ride.status)"
|
||||
>
|
||||
{{ ride.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Results -->
|
||||
<div
|
||||
v-else-if="!loading && parks.length === 0 && rides.length === 0 && hasSearched"
|
||||
class="text-center py-12"
|
||||
>
|
||||
<div class="text-gray-400 dark:text-gray-500 mb-4">
|
||||
<svg class="h-16 w-16 mx-auto" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">No results found</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-6">
|
||||
We couldn't find any parks or rides matching your search.
|
||||
</p>
|
||||
<div class="space-y-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<p>Try:</p>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li>Checking your spelling</li>
|
||||
<li>Using more general terms</li>
|
||||
<li>Removing filters</li>
|
||||
<li>Searching for specific park or ride names</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Initial State -->
|
||||
<div v-else-if="!hasSearched" class="text-center py-12">
|
||||
<div class="text-gray-400 dark:text-gray-500 mb-4">
|
||||
<svg class="h-16 w-16 mx-auto" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Search ThrillerWiki
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Enter a search term above to find parks, rides, and more.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '@/services/api'
|
||||
import type { Park, Ride } from '@/types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const hasSearched = ref(false)
|
||||
const currentSearchQuery = ref('')
|
||||
const selectedCategory = ref('')
|
||||
const parks = ref<Park[]>([])
|
||||
const rides = ref<Ride[]>([])
|
||||
|
||||
const searchQuery = computed(() => (route.query.q as string) || '')
|
||||
const totalResults = computed(() => parks.value.length + rides.value.length)
|
||||
|
||||
const performSearch = async () => {
|
||||
if (!currentSearchQuery.value.trim()) return
|
||||
|
||||
// Update URL with search parameters
|
||||
await router.push({
|
||||
path: '/search/',
|
||||
query: {
|
||||
q: currentSearchQuery.value,
|
||||
...(selectedCategory.value && { category: selectedCategory.value }),
|
||||
},
|
||||
})
|
||||
|
||||
await searchItems()
|
||||
}
|
||||
|
||||
const searchItems = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
hasSearched.value = true
|
||||
|
||||
const query = route.query.q as string
|
||||
const category = route.query.category as string
|
||||
|
||||
if (!query) {
|
||||
parks.value = []
|
||||
rides.value = []
|
||||
return
|
||||
}
|
||||
|
||||
// Use real API calls based on category
|
||||
if (category === 'parks') {
|
||||
// Search only parks
|
||||
const parksResult = await api.parks.searchParks(query)
|
||||
parks.value = parksResult.results
|
||||
rides.value = []
|
||||
} else if (category === 'rides') {
|
||||
// Search only rides
|
||||
const ridesResult = await api.rides.searchRides(query)
|
||||
parks.value = []
|
||||
rides.value = ridesResult.results
|
||||
} else {
|
||||
// Global search for both parks and rides
|
||||
const searchResult = await api.globalSearch(query)
|
||||
parks.value = searchResult.parks.results
|
||||
rides.value = searchResult.rides.results
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error)
|
||||
// Reset results on error
|
||||
parks.value = []
|
||||
rides.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusClasses = (status: string) => {
|
||||
switch (status?.toLowerCase()) {
|
||||
case 'operating':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100'
|
||||
case 'closed':
|
||||
case 'sbno':
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100'
|
||||
case 'construction':
|
||||
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-100'
|
||||
}
|
||||
}
|
||||
|
||||
const navigateToPark = (park: Park) => {
|
||||
router.push(`/parks/${park.slug}/`)
|
||||
}
|
||||
|
||||
const navigateToRide = (ride: Ride) => {
|
||||
router.push(`/parks/${ride.parkSlug}/rides/${ride.slug}/`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Initialize search query from URL
|
||||
currentSearchQuery.value = searchQuery.value
|
||||
selectedCategory.value = (route.query.category as string) || ''
|
||||
|
||||
// Perform search if query exists
|
||||
if (searchQuery.value) {
|
||||
searchItems()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
64
frontend/src/views/accounts/ForgotPassword.vue
Normal file
64
frontend/src/views/accounts/ForgotPassword.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-gray-50 dark:bg-gray-900 min-h-screen flex items-center justify-center py-8 md:py-12"
|
||||
>
|
||||
<div class="w-full max-w-lg px-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8">
|
||||
<h1 class="text-2xl lg:text-3xl font-bold text-gray-900 dark:text-white text-center mb-8">
|
||||
Reset Your Password
|
||||
</h1>
|
||||
|
||||
<p class="text-gray-600 dark:text-gray-400 text-center mb-8">
|
||||
Enter your email address and we'll send you a link to reset your password
|
||||
</p>
|
||||
|
||||
<!-- Reset Password Button -->
|
||||
<div class="mb-6">
|
||||
<button
|
||||
@click="showForgotPassword"
|
||||
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 transition-colors"
|
||||
>
|
||||
Reset Password
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Back to Login -->
|
||||
<div class="text-sm text-center">
|
||||
<router-link
|
||||
to="/auth/login"
|
||||
class="font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300"
|
||||
>
|
||||
← Back to Sign In
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Forgot Password Modal -->
|
||||
<ForgotPasswordModal :show="showModal" @close="hideModal" @success="handleSuccess" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ForgotPasswordModal from '@/components/auth/ForgotPasswordModal.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const showModal = ref(false)
|
||||
|
||||
const showForgotPassword = () => {
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
const hideModal = () => {
|
||||
showModal.value = false
|
||||
}
|
||||
|
||||
const handleSuccess = () => {
|
||||
hideModal()
|
||||
// Optionally redirect to login page with success message
|
||||
router.push('/auth/login')
|
||||
}
|
||||
</script>
|
||||
78
frontend/src/views/accounts/Login.vue
Normal file
78
frontend/src/views/accounts/Login.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-gray-50 dark:bg-gray-900 min-h-screen flex items-center justify-center py-8 md:py-12"
|
||||
>
|
||||
<div class="w-full max-w-lg px-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8">
|
||||
<h1 class="text-2xl lg:text-3xl font-bold text-gray-900 dark:text-white text-center mb-8">
|
||||
Welcome to ThrillWiki
|
||||
</h1>
|
||||
|
||||
<p class="text-gray-600 dark:text-gray-400 text-center mb-8">
|
||||
Please sign in to continue or create a new account
|
||||
</p>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="space-y-4">
|
||||
<button
|
||||
@click="showAuth('login')"
|
||||
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 transition-colors"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="showAuth('signup')"
|
||||
class="w-full flex justify-center py-3 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 transition-colors"
|
||||
>
|
||||
Create Account
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Continue as Guest -->
|
||||
<div class="mt-6 text-sm text-center">
|
||||
<router-link
|
||||
to="/"
|
||||
class="font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300"
|
||||
>
|
||||
Continue as Guest
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auth Manager for Modals -->
|
||||
<AuthManager
|
||||
:show="showAuthModal"
|
||||
:initial-mode="authMode"
|
||||
@close="hideAuth"
|
||||
@success="handleAuthSuccess"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import AuthManager from '@/components/auth/AuthManager.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const showAuthModal = ref(false)
|
||||
const authMode = ref<'login' | 'signup'>('login')
|
||||
|
||||
const showAuth = (mode: 'login' | 'signup') => {
|
||||
authMode.value = mode
|
||||
showAuthModal.value = true
|
||||
}
|
||||
|
||||
const hideAuth = () => {
|
||||
showAuthModal.value = false
|
||||
}
|
||||
|
||||
const handleAuthSuccess = () => {
|
||||
hideAuth()
|
||||
// Redirect to home or intended route
|
||||
router.push('/')
|
||||
}
|
||||
</script>
|
||||
78
frontend/src/views/accounts/Signup.vue
Normal file
78
frontend/src/views/accounts/Signup.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-gray-50 dark:bg-gray-900 min-h-screen flex items-center justify-center py-8 md:py-12"
|
||||
>
|
||||
<div class="w-full max-w-lg px-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8">
|
||||
<h1 class="text-2xl lg:text-3xl font-bold text-gray-900 dark:text-white text-center mb-8">
|
||||
Join ThrillWiki
|
||||
</h1>
|
||||
|
||||
<p class="text-gray-600 dark:text-gray-400 text-center mb-8">
|
||||
Create your account to start exploring the world of theme parks
|
||||
</p>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="space-y-4">
|
||||
<button
|
||||
@click="showAuth('signup')"
|
||||
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 transition-colors"
|
||||
>
|
||||
Create Account
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="showAuth('login')"
|
||||
class="w-full flex justify-center py-3 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 transition-colors"
|
||||
>
|
||||
Sign In Instead
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Continue as Guest -->
|
||||
<div class="mt-6 text-sm text-center">
|
||||
<router-link
|
||||
to="/"
|
||||
class="font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300"
|
||||
>
|
||||
Continue as Guest
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auth Manager for Modals -->
|
||||
<AuthManager
|
||||
:show="showAuthModal"
|
||||
:initial-mode="authMode"
|
||||
@close="hideAuth"
|
||||
@success="handleAuthSuccess"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import AuthManager from '@/components/auth/AuthManager.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const showAuthModal = ref(false)
|
||||
const authMode = ref<'login' | 'signup'>('signup')
|
||||
|
||||
const showAuth = (mode: 'login' | 'signup') => {
|
||||
authMode.value = mode
|
||||
showAuthModal.value = true
|
||||
}
|
||||
|
||||
const hideAuth = () => {
|
||||
showAuthModal.value = false
|
||||
}
|
||||
|
||||
const handleAuthSuccess = () => {
|
||||
hideAuth()
|
||||
// Redirect to home or intended route
|
||||
router.push('/')
|
||||
}
|
||||
</script>
|
||||
233
frontend/src/views/parks/ParkDetail.vue
Normal file
233
frontend/src/views/parks/ParkDetail.vue
Normal file
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<div class="bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="text-center">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading park details...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6"
|
||||
>
|
||||
<h1 class="text-2xl font-bold text-red-800 dark:text-red-200 mb-2">Park Not Found</h1>
|
||||
<p class="text-red-600 dark:text-red-400">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Park Content -->
|
||||
<div v-else-if="park">
|
||||
<!-- Park Header -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 mb-6">
|
||||
<div class="text-center">
|
||||
<h1 class="text-3xl lg:text-4xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
{{ park.name }}
|
||||
</h1>
|
||||
<div
|
||||
v-if="park.location"
|
||||
class="flex items-center justify-center mt-2 text-sm text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
<svg class="h-4 w-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
<p>{{ park.location }}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-center gap-2 mt-3">
|
||||
<span
|
||||
class="px-3 py-1 text-sm font-medium rounded-full bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100"
|
||||
>
|
||||
Operating
|
||||
</span>
|
||||
<span
|
||||
v-if="park.average_rating"
|
||||
class="flex items-center px-3 py-1 text-sm font-medium text-yellow-800 bg-yellow-100 dark:bg-yellow-600 dark:text-yellow-50 rounded-full"
|
||||
>
|
||||
<svg
|
||||
class="h-3 w-3 text-yellow-500 dark:text-yellow-200 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
|
||||
></path>
|
||||
</svg>
|
||||
{{ park.average_rating.toFixed(1) }}/10
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 text-center">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">Total Rides</div>
|
||||
<div class="mt-1 text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{{ park.ride_count || 'N/A' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 text-center">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">Roller Coasters</div>
|
||||
<div class="mt-1 text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{{ park.coaster_count || 'N/A' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 text-center">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">Opened</div>
|
||||
<div class="mt-1 text-sm font-bold text-blue-600 dark:text-blue-400">
|
||||
{{ formatDate(park.opening_date) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="park.website"
|
||||
class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 text-center"
|
||||
>
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">Website</div>
|
||||
<div class="mt-1">
|
||||
<a
|
||||
:href="park.website"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm font-bold text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
|
||||
>
|
||||
Visit
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div
|
||||
v-if="park.description"
|
||||
class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 mb-6"
|
||||
>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">About</h2>
|
||||
<div class="prose dark:prose-invert max-w-none">
|
||||
<p class="text-gray-700 dark:text-gray-300 whitespace-pre-line">
|
||||
{{ park.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rides and Attractions -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Rides & Attractions</h2>
|
||||
<button
|
||||
v-if="rides && rides.length > 6"
|
||||
class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 text-sm font-medium"
|
||||
>
|
||||
View All ({{ rides.length }})
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="rides && rides.length > 0" class="grid gap-4 md:grid-cols-2">
|
||||
<div
|
||||
v-for="ride in rides.slice(0, 6)"
|
||||
:key="ride.id"
|
||||
class="p-4 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer"
|
||||
@click="navigateToRide(ride.slug)"
|
||||
>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{{ ride.name }}
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
<span
|
||||
class="inline-block px-2 py-1 text-xs font-medium text-blue-800 bg-blue-100 dark:bg-blue-700 dark:text-blue-50 rounded-full"
|
||||
>
|
||||
{{ ride.category }}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="ride.description"
|
||||
class="text-gray-600 dark:text-gray-400 text-sm mt-2 line-clamp-2"
|
||||
>
|
||||
{{ ride.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-gray-500 dark:text-gray-400 text-center py-8">
|
||||
<svg
|
||||
class="h-12 w-12 mx-auto mb-4 text-gray-300 dark:text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
></path>
|
||||
</svg>
|
||||
<p>No rides or attractions listed yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '@/services/api'
|
||||
import type { Park, Ride } from '@/types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const park = ref<Park | null>(null)
|
||||
const rides = ref<Ride[]>([])
|
||||
|
||||
const fetchPark = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const parkSlug = route.params.slug as string
|
||||
|
||||
// Fetch park data from real API
|
||||
const [parkData, ridesData] = await Promise.all([
|
||||
api.parks.getPark(parkSlug),
|
||||
api.parks.getParkRides(parkSlug),
|
||||
])
|
||||
|
||||
park.value = parkData
|
||||
rides.value = ridesData.results
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Park not found'
|
||||
console.error('Failed to fetch park:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return 'N/A'
|
||||
return new Date(dateString).getFullYear().toString()
|
||||
}
|
||||
|
||||
const navigateToRide = (rideSlug: string) => {
|
||||
router.push(`/parks/${park.value?.slug}/rides/${rideSlug}/`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchPark()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
215
frontend/src/views/parks/ParkList.vue
Normal file
215
frontend/src/views/parks/ParkList.vue
Normal file
@@ -0,0 +1,215 @@
|
||||
<template>
|
||||
<div class="bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl lg:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Theme Parks
|
||||
</h1>
|
||||
<p class="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||
Discover amazing theme parks around the world. Find detailed information about rides,
|
||||
attractions, and experiences.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filters -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 mb-8">
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search parks..."
|
||||
class="w-full px-4 py-2 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"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
v-model="selectedCountry"
|
||||
class="px-4 py-2 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"
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<option value="">All Countries</option>
|
||||
<option value="US">United States</option>
|
||||
<option value="UK">United Kingdom</option>
|
||||
<option value="DE">Germany</option>
|
||||
<option value="FR">France</option>
|
||||
<option value="JP">Japan</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading parks...</p>
|
||||
</div>
|
||||
|
||||
<!-- Parks Grid -->
|
||||
<div v-else-if="filteredParks.length > 0" class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div
|
||||
v-for="park in filteredParks"
|
||||
:key="park.id"
|
||||
class="bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden hover:shadow-xl transition-shadow cursor-pointer"
|
||||
@click="navigateToPark(park.slug)"
|
||||
>
|
||||
<!-- Park Image -->
|
||||
<div class="h-48 bg-gradient-to-br from-blue-500 via-purple-500 to-pink-500 relative">
|
||||
<div class="absolute inset-0 bg-black bg-opacity-20"></div>
|
||||
<div class="absolute top-4 right-4">
|
||||
<span
|
||||
v-if="park.featured"
|
||||
class="px-2 py-1 text-xs font-medium text-white bg-yellow-500 rounded-full"
|
||||
>
|
||||
Featured
|
||||
</span>
|
||||
</div>
|
||||
<div class="absolute bottom-4 left-4">
|
||||
<h3 class="text-white text-xl font-bold">{{ park.name }}</h3>
|
||||
<p class="text-white text-sm opacity-90">{{ park.location }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Park Info -->
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center">
|
||||
<svg class="h-4 w-4 text-yellow-500 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
|
||||
></path>
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ park.average_rating ? park.average_rating.toFixed(1) : 'N/A' }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="px-2 py-1 text-xs font-medium text-green-800 bg-green-100 dark:bg-green-800 dark:text-green-100 rounded-full"
|
||||
>
|
||||
{{ park.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm mb-4 line-clamp-2">
|
||||
{{ park.description }}
|
||||
</p>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="flex justify-between text-sm text-gray-600 dark:text-gray-400">
|
||||
<div>
|
||||
<span class="font-medium">{{ park.ride_count || 0 }}</span> rides
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">{{ park.coaster_count || 0 }}</span> coasters
|
||||
</div>
|
||||
<div>Est. {{ formatYear(park.opening_date) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="text-center py-12">
|
||||
<svg
|
||||
class="h-16 w-16 mx-auto text-gray-300 dark:text-gray-600 mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
></path>
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No parks found</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400">
|
||||
Try adjusting your search or filter criteria.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api } from '@/services/api'
|
||||
import type { Park } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(true)
|
||||
const parks = ref<Park[]>([])
|
||||
const searchQuery = ref('')
|
||||
const selectedCountry = ref('')
|
||||
|
||||
const filteredParks = computed(() => {
|
||||
let result = parks.value
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
result = result.filter(
|
||||
(park) =>
|
||||
park.name.toLowerCase().includes(query) ||
|
||||
park.location.toLowerCase().includes(query) ||
|
||||
park.description.toLowerCase().includes(query),
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedCountry.value) {
|
||||
result = result.filter((park) => park.country === selectedCountry.value)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const fetchParks = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
// Fetch parks from real API
|
||||
const response = await api.parks.getParks({
|
||||
search: searchQuery.value || undefined,
|
||||
ordering: '-average_rating',
|
||||
})
|
||||
|
||||
parks.value = response.results
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch parks:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatYear = (dateString: string | null) => {
|
||||
if (!dateString) return 'N/A'
|
||||
return new Date(dateString).getFullYear()
|
||||
}
|
||||
|
||||
const navigateToPark = (slug: string) => {
|
||||
router.push(`/parks/${slug}/`)
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
// Search is handled reactively through computed property
|
||||
}
|
||||
|
||||
const handleFilterChange = () => {
|
||||
// Filter is handled reactively through computed property
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchParks()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
267
frontend/src/views/rides/RideDetail.vue
Normal file
267
frontend/src/views/rides/RideDetail.vue
Normal file
@@ -0,0 +1,267 @@
|
||||
<template>
|
||||
<div class="bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="text-center">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading ride details...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6"
|
||||
>
|
||||
<h1 class="text-2xl font-bold text-red-800 dark:text-red-200 mb-2">Ride Not Found</h1>
|
||||
<p class="text-red-600 dark:text-red-400">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Ride Content -->
|
||||
<div v-else-if="ride">
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="mb-6" aria-label="Breadcrumb">
|
||||
<ol class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<li>
|
||||
<router-link to="/parks/" class="hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>Parks</router-link
|
||||
>
|
||||
</li>
|
||||
<li>/</li>
|
||||
<li>
|
||||
<router-link
|
||||
:to="`/parks/${ride.parkSlug}/`"
|
||||
class="hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>{{ ride.parkName }}</router-link
|
||||
>
|
||||
</li>
|
||||
<li>/</li>
|
||||
<li>
|
||||
<router-link
|
||||
:to="`/parks/${ride.parkSlug}/rides/`"
|
||||
class="hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>Rides</router-link
|
||||
>
|
||||
</li>
|
||||
<li>/</li>
|
||||
<li class="font-medium text-gray-900 dark:text-white">{{ ride.name }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Ride Header -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 mb-6">
|
||||
<div class="text-center">
|
||||
<h1 class="text-3xl lg:text-4xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
{{ ride.name }}
|
||||
</h1>
|
||||
<p class="text-lg text-gray-600 dark:text-gray-400 mb-4">at {{ ride.parkName }}</p>
|
||||
<div class="flex flex-wrap items-center justify-center gap-2 mt-3">
|
||||
<span
|
||||
class="px-3 py-1 text-sm font-medium text-blue-800 bg-blue-100 dark:bg-blue-700 dark:text-blue-50 rounded-full"
|
||||
>
|
||||
{{ ride.category }}
|
||||
</span>
|
||||
<span
|
||||
v-if="ride.status"
|
||||
class="px-3 py-1 text-sm font-medium rounded-full"
|
||||
:class="getStatusClasses(ride.status)"
|
||||
>
|
||||
{{ ride.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ride Stats Grid -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div
|
||||
v-if="ride.height"
|
||||
class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 text-center"
|
||||
>
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">Height</div>
|
||||
<div class="mt-1 text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{{ ride.height }}ft
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="ride.speed"
|
||||
class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 text-center"
|
||||
>
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">Top Speed</div>
|
||||
<div class="mt-1 text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{{ ride.speed }}mph
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="ride.length"
|
||||
class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 text-center"
|
||||
>
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">Length</div>
|
||||
<div class="mt-1 text-lg font-bold text-blue-600 dark:text-blue-400">
|
||||
{{ formatLength(ride.length) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="ride.duration"
|
||||
class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 text-center"
|
||||
>
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">Duration</div>
|
||||
<div class="mt-1 text-lg font-bold text-blue-600 dark:text-blue-400">
|
||||
{{ ride.duration }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div
|
||||
v-if="ride.description"
|
||||
class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 mb-6"
|
||||
>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">About This Ride</h2>
|
||||
<div class="prose dark:prose-invert max-w-none">
|
||||
<p class="text-gray-700 dark:text-gray-300 whitespace-pre-line">
|
||||
{{ ride.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Technical Details -->
|
||||
<div
|
||||
v-if="ride.manufacturer || ride.designer || ride.openingDate"
|
||||
class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 mb-6"
|
||||
>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Technical Details
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div v-if="ride.manufacturer" class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div class="text-sm font-semibold text-gray-500 dark:text-gray-400">Manufacturer</div>
|
||||
<div class="mt-1 text-lg font-medium text-gray-900 dark:text-white">
|
||||
{{ ride.manufacturer }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="ride.designer" class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div class="text-sm font-semibold text-gray-500 dark:text-gray-400">Designer</div>
|
||||
<div class="mt-1 text-lg font-medium text-gray-900 dark:text-white">
|
||||
{{ ride.designer }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="ride.openingDate" class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div class="text-sm font-semibold text-gray-500 dark:text-gray-400">Opened</div>
|
||||
<div class="mt-1 text-lg font-medium text-gray-900 dark:text-white">
|
||||
{{ formatDate(ride.openingDate) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="ride.inversions" class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div class="text-sm font-semibold text-gray-500 dark:text-gray-400">Inversions</div>
|
||||
<div class="mt-1 text-lg font-medium text-gray-900 dark:text-white">
|
||||
{{ ride.inversions }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="ride.capacity" class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div class="text-sm font-semibold text-gray-500 dark:text-gray-400">Capacity</div>
|
||||
<div class="mt-1 text-lg font-medium text-gray-900 dark:text-white">
|
||||
{{ ride.capacity }} riders/hour
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-wrap gap-4 justify-center">
|
||||
<button
|
||||
@click="navigateToPark"
|
||||
class="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
View Park Details
|
||||
</button>
|
||||
<button
|
||||
@click="navigateToAllRides"
|
||||
class="px-6 py-3 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
View All Park Rides
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '@/services/api'
|
||||
import type { Ride } from '@/types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const ride = ref<Ride | null>(null)
|
||||
|
||||
const fetchRide = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const parkSlug = route.params.parkSlug as string
|
||||
const rideSlug = route.params.rideSlug as string
|
||||
|
||||
// Fetch ride data from real API
|
||||
const rideData = await api.rides.getRide(parkSlug, rideSlug)
|
||||
ride.value = rideData
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Ride not found'
|
||||
console.error('Failed to fetch ride:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusClasses = (status: string) => {
|
||||
switch (status?.toLowerCase()) {
|
||||
case 'operating':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100'
|
||||
case 'closed':
|
||||
case 'sbno':
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100'
|
||||
case 'construction':
|
||||
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-100'
|
||||
}
|
||||
}
|
||||
|
||||
const formatLength = (length: number) => {
|
||||
if (length >= 1000) {
|
||||
return `${(length / 1000).toFixed(1)}k ft`
|
||||
}
|
||||
return `${length} ft`
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return 'N/A'
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
const navigateToPark = () => {
|
||||
if (ride.value) {
|
||||
router.push(`/parks/${ride.value.parkSlug}/`)
|
||||
}
|
||||
}
|
||||
|
||||
const navigateToAllRides = () => {
|
||||
if (ride.value) {
|
||||
router.push(`/parks/${ride.value.parkSlug}/rides/`)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchRide()
|
||||
})
|
||||
</script>
|
||||
276
frontend/src/views/rides/RideList.vue
Normal file
276
frontend/src/views/rides/RideList.vue
Normal file
@@ -0,0 +1,276 @@
|
||||
<template>
|
||||
<div class="bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl lg:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{{ parkSlug ? `${parkName} Rides` : 'All Rides' }}
|
||||
</h1>
|
||||
<p v-if="parkSlug" class="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||
Explore all the thrilling rides and attractions at {{ parkName }}.
|
||||
</p>
|
||||
<p v-else class="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||
Discover amazing rides and attractions from theme parks around the world.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Breadcrumb for park-specific rides -->
|
||||
<nav v-if="parkSlug" class="mb-6" aria-label="Breadcrumb">
|
||||
<ol class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<li>
|
||||
<router-link to="/parks/" class="hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>Parks</router-link
|
||||
>
|
||||
</li>
|
||||
<li>/</li>
|
||||
<li>
|
||||
<router-link
|
||||
:to="`/parks/${parkSlug}/`"
|
||||
class="hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>{{ parkName }}</router-link
|
||||
>
|
||||
</li>
|
||||
<li>/</li>
|
||||
<li class="font-medium text-gray-900 dark:text-white">Rides</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Search and Filters -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 mb-8">
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search rides..."
|
||||
class="w-full px-4 py-2 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"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
v-model="selectedCategory"
|
||||
class="px-4 py-2 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"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
<option value="roller-coaster">Roller Coasters</option>
|
||||
<option value="water-ride">Water Rides</option>
|
||||
<option value="family-ride">Family Rides</option>
|
||||
<option value="thrill-ride">Thrill Rides</option>
|
||||
<option value="dark-ride">Dark Rides</option>
|
||||
</select>
|
||||
<select
|
||||
v-model="sortBy"
|
||||
class="px-4 py-2 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"
|
||||
>
|
||||
<option value="name">Sort by Name</option>
|
||||
<option value="rating">Sort by Rating</option>
|
||||
<option value="height">Sort by Height</option>
|
||||
<option value="speed">Sort by Speed</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading rides...</p>
|
||||
</div>
|
||||
|
||||
<!-- Rides Grid -->
|
||||
<div v-else-if="filteredRides.length > 0" class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div
|
||||
v-for="ride in filteredRides"
|
||||
:key="ride.id"
|
||||
class="bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden hover:shadow-xl transition-shadow cursor-pointer"
|
||||
@click="navigateToRide(ride)"
|
||||
>
|
||||
<!-- Ride Image -->
|
||||
<div class="h-48 bg-gradient-to-br from-purple-500 via-blue-500 to-teal-500 relative">
|
||||
<div class="absolute inset-0 bg-black bg-opacity-20"></div>
|
||||
<div class="absolute top-4 right-4">
|
||||
<span
|
||||
v-if="ride.featured"
|
||||
class="px-2 py-1 text-xs font-medium text-white bg-yellow-500 rounded-full"
|
||||
>
|
||||
Featured
|
||||
</span>
|
||||
</div>
|
||||
<div class="absolute bottom-4 left-4">
|
||||
<h3 class="text-white text-xl font-bold">{{ ride.name }}</h3>
|
||||
<p class="text-white text-sm opacity-90">{{ ride.park_name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ride Info -->
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center">
|
||||
<svg class="h-4 w-4 text-yellow-500 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
|
||||
></path>
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ ride.average_rating ? ride.average_rating.toFixed(1) : 'N/A' }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="px-2 py-1 text-xs font-medium text-blue-800 bg-blue-100 dark:bg-blue-700 dark:text-blue-50 rounded-full"
|
||||
>
|
||||
{{ ride.category_display }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm mb-4 line-clamp-2">
|
||||
{{ ride.description }}
|
||||
</p>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div v-if="ride.height" class="text-gray-600 dark:text-gray-400">
|
||||
<span class="font-medium">Height:</span> {{ ride.height }}ft
|
||||
</div>
|
||||
<div v-if="ride.speed" class="text-gray-600 dark:text-gray-400">
|
||||
<span class="font-medium">Speed:</span> {{ ride.speed }}mph
|
||||
</div>
|
||||
<div v-if="ride.length" class="text-gray-600 dark:text-gray-400">
|
||||
<span class="font-medium">Length:</span> {{ ride.length }}ft
|
||||
</div>
|
||||
<div v-if="ride.duration" class="text-gray-600 dark:text-gray-400">
|
||||
<span class="font-medium">Duration:</span> {{ ride.duration }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="text-center py-12">
|
||||
<svg
|
||||
class="h-16 w-16 mx-auto text-gray-300 dark:text-gray-600 mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
></path>
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No rides found</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400">
|
||||
Try adjusting your search or filter criteria.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '@/services/api'
|
||||
import type { Ride } from '@/types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(true)
|
||||
const rides = ref<Ride[]>([])
|
||||
const searchQuery = ref('')
|
||||
const selectedCategory = ref('')
|
||||
const sortBy = ref('name')
|
||||
|
||||
// Get park context from route params
|
||||
const parkSlug = computed(() => route.params.parkSlug as string)
|
||||
const parkName = ref('')
|
||||
|
||||
const filteredRides = computed(() => {
|
||||
let result = rides.value
|
||||
|
||||
// Apply search filter
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
result = result.filter(
|
||||
(ride) =>
|
||||
ride.name.toLowerCase().includes(query) ||
|
||||
ride.description.toLowerCase().includes(query) ||
|
||||
ride.park_name.toLowerCase().includes(query),
|
||||
)
|
||||
}
|
||||
|
||||
// Apply category filter
|
||||
if (selectedCategory.value) {
|
||||
result = result.filter((ride) => ride.category === selectedCategory.value)
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
result.sort((a, b) => {
|
||||
switch (sortBy.value) {
|
||||
case 'rating':
|
||||
return (b.average_rating || 0) - (a.average_rating || 0)
|
||||
case 'height':
|
||||
return (b.height || 0) - (a.height || 0)
|
||||
case 'speed':
|
||||
return (b.speed || 0) - (a.speed || 0)
|
||||
default:
|
||||
return a.name.localeCompare(b.name)
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const fetchRides = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
let response
|
||||
|
||||
if (parkSlug.value) {
|
||||
// Fetch rides for specific park
|
||||
response = await api.parks.getParkRides(parkSlug.value)
|
||||
|
||||
// Also fetch park info to get the park name
|
||||
try {
|
||||
const parkData = await api.parks.getPark(parkSlug.value)
|
||||
parkName.value = parkData.name
|
||||
} catch (err) {
|
||||
console.warn('Could not fetch park name:', err)
|
||||
}
|
||||
|
||||
rides.value = response.results
|
||||
} else {
|
||||
// Fetch all rides
|
||||
response = await api.rides.getRides({
|
||||
search: searchQuery.value || undefined,
|
||||
ordering: sortBy.value === 'rating' ? '-average_rating' : sortBy.value,
|
||||
})
|
||||
|
||||
rides.value = response.results
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch rides:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const navigateToRide = (ride: Ride) => {
|
||||
router.push(`/parks/${ride.parkSlug}/rides/${ride.slug}/`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchRides()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -8,27 +8,27 @@ export default {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
950: '#172554',
|
||||
purple: {
|
||||
50: '#faf5ff',
|
||||
100: '#f3e8ff',
|
||||
200: '#e9d5ff',
|
||||
300: '#d8b4fe',
|
||||
400: '#c084fc',
|
||||
500: '#a855f7',
|
||||
600: '#9333ea',
|
||||
700: '#7c3aed',
|
||||
800: '#6b21a8',
|
||||
900: '#581c87',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
boxShadow: {
|
||||
'lg': '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||
'xl': '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/forms'),
|
||||
require('@tailwindcss/typography'),
|
||||
],
|
||||
plugins: [],
|
||||
}
|
||||
12
frontend/tsconfig.app.json
Normal file
12
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": [
|
||||
"ES2020",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
/* Path mapping */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.vitest.json"
|
||||
}
|
||||
],
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"nightwatch.conf.*",
|
||||
"playwright.config.*",
|
||||
"eslint.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
|
||||
11
frontend/tsconfig.vitest.json
Normal file
11
frontend/tsconfig.vitest.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
"include": ["src/**/__tests__/*", "env.d.ts"],
|
||||
"exclude": [],
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo",
|
||||
|
||||
"lib": [],
|
||||
"types": ["node", "jsdom"]
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,53 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
import vueJsx from '@vitejs/plugin-vue-jsx'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(({ mode }) => ({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueJsx(),
|
||||
vueDevTools(),
|
||||
tailwindcss(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
target: 'http://127.0.0.1:8000',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/media': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
ws: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, '/api/v1')
|
||||
}
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
sourcemap: true,
|
||||
},
|
||||
})
|
||||
sourcemap: mode === 'development',
|
||||
minify: mode === 'production',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes('node_modules')) {
|
||||
if (id.includes('vue') || id.includes('pinia') || id.includes('vue-router')) {
|
||||
return 'vue-vendor';
|
||||
}
|
||||
if (id.includes('lucide-vue-next')) {
|
||||
return 'ui-vendor';
|
||||
}
|
||||
return 'vendor';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
14
frontend/vitest.config.ts
Normal file
14
frontend/vitest.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
|
||||
import viteConfig from './vite.config'
|
||||
|
||||
export default mergeConfig(
|
||||
viteConfig,
|
||||
defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
exclude: [...configDefaults.exclude, 'e2e/**'],
|
||||
root: fileURLToPath(new URL('./', import.meta.url)),
|
||||
},
|
||||
}),
|
||||
)
|
||||
Reference in New Issue
Block a user