feat: major API restructure and Vue.js frontend integration

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

8
frontend/.editorconfig Normal file
View File

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

View File

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

6
frontend/.env.production Normal file
View File

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

6
frontend/.env.staging Normal file
View File

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

1
frontend/.gitattributes vendored Normal file
View File

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

33
frontend/.gitignore vendored Normal file
View File

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

View File

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

View File

@@ -1,49 +1,73 @@
# ThrillWiki Frontend
Vue.js SPA frontend for the ThrillWiki monorepo.
Modern Vue.js 3 SPA frontend for the ThrillWiki theme park and roller coaster information system.
## 🏗️ Architecture
## 🏗️ Architecture Overview
Modern Vue 3 application with TypeScript and composition API:
This frontend is built with Vue 3 and follows modern development practices:
```
frontend/
├── src/
│ ├── components/ # Vue components
│ │ ├── common/ # Shared components
│ │ ├── parks/ # Park-specific components
│ │ ├── rides/ # Ride-specific components
│ │ ── moderation/ # Moderation components
├── 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
View File

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

View File

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 4.2 KiB

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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')

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

181
frontend/src/types/index.ts Normal file
View 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
}

View 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
View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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: [],
}

View 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/*"]
}
}
}

View File

@@ -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/*"
]
}
}
}

View File

@@ -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"]
}
}

View 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"]
}
}

View File

@@ -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
View 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)),
},
}),
)