Add openai integration
This commit is contained in:
parent
9cf1a287a7
commit
a331d1a8da
@ -1,22 +0,0 @@
|
|||||||
---
|
|
||||||
description:
|
|
||||||
globs:
|
|
||||||
alwaysApply: true
|
|
||||||
---
|
|
||||||
# Description
|
|
||||||
|
|
||||||
This a fullstack nextjs project, all written in mordern and state of the art typescript.
|
|
||||||
The Project follows the latest coding standards for next js projects
|
|
||||||
|
|
||||||
# Modules
|
|
||||||
|
|
||||||
This Project will use as less modules and node packages as possible. This leads to the following rules:
|
|
||||||
- No Tailwind
|
|
||||||
- No SCSS / SASS
|
|
||||||
|
|
||||||
# Code Style
|
|
||||||
|
|
||||||
This project follows clean code rules:
|
|
||||||
- SOLID Principles
|
|
||||||
- No else statements
|
|
||||||
- readable name
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
---
|
|
||||||
description:
|
|
||||||
globs:
|
|
||||||
alwaysApply: true
|
|
||||||
---
|
|
||||||
# EWIKI
|
|
||||||
|
|
||||||
The EWIKI is a eletric vehicle database, that allows the user to search and compare any information about eletric vehicles.
|
|
||||||
|
|
||||||
# Structure
|
|
||||||
|
|
||||||
## Start page
|
|
||||||
|
|
||||||
The start page looks like a modern web search engine. There are no fancy input and filter options, just a regular search bar
|
|
||||||
10
.gitignore
vendored
10
.gitignore
vendored
@ -39,3 +39,13 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
###> symfony/framework-bundle ###
|
||||||
|
/.env.local
|
||||||
|
/.env.local.php
|
||||||
|
/.env.*.local
|
||||||
|
/config/secrets/prod/prod.decrypt.private.php
|
||||||
|
/public/bundles/
|
||||||
|
/var/
|
||||||
|
/vendor/
|
||||||
|
###< symfony/framework-bundle ###
|
||||||
|
|||||||
@ -1,12 +1,37 @@
|
|||||||
FROM node:18-alpine
|
FROM php:8.4-fpm-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
# Install system dependencies including zsh
|
||||||
COPY package.json package-lock.json ./
|
RUN apk add --no-cache \
|
||||||
|
git \
|
||||||
|
curl \
|
||||||
|
libpng-dev \
|
||||||
|
libxml2-dev \
|
||||||
|
zip \
|
||||||
|
unzip \
|
||||||
|
openssl-dev \
|
||||||
|
autoconf \
|
||||||
|
g++ \
|
||||||
|
make \
|
||||||
|
zsh \
|
||||||
|
wget
|
||||||
|
|
||||||
# Install dependencies
|
# Install PHP extensions
|
||||||
RUN npm install
|
RUN docker-php-ext-install pdo pdo_mysql
|
||||||
|
|
||||||
|
# Install MongoDB PHP extension
|
||||||
|
RUN pecl install mongodb \
|
||||||
|
&& docker-php-ext-enable mongodb
|
||||||
|
|
||||||
|
# Install Composer
|
||||||
|
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
|
# Install Oh My Zsh
|
||||||
|
RUN sh -c "$(wget -O- https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended
|
||||||
|
|
||||||
|
# Set zsh as default shell
|
||||||
|
RUN chsh -s $(which zsh)
|
||||||
|
|
||||||
# CMD will be executed when container starts
|
# CMD will be executed when container starts
|
||||||
CMD ["npm", "run", "dev"]
|
CMD ["php", "-S", "0.0.0.0:3000", "-t", "public"]
|
||||||
178
README.md
178
README.md
@ -1,36 +1,170 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
# E-WIKI - Electric Vehicle Database
|
||||||
|
|
||||||
## Getting Started
|
A modern Symfony application for browsing and searching electric vehicle information. This application provides a clean, search-engine-like interface for exploring electric vehicle brands, models, and specifications.
|
||||||
|
|
||||||
First, run the development server:
|
## Features
|
||||||
|
|
||||||
```bash
|
- **Modern Search Interface**: Google-like search experience for electric vehicles
|
||||||
npm run dev
|
- **Brand Directory**: Browse popular electric vehicle manufacturers
|
||||||
# or
|
- **Vehicle Database**: Comprehensive information about electric car models and revisions
|
||||||
yarn dev
|
- **RESTful API**: JSON API endpoints for integration
|
||||||
# or
|
- **MongoDB Integration**: NoSQL database for flexible data storage
|
||||||
pnpm dev
|
- **Responsive Design**: Mobile-friendly interface without external CSS frameworks
|
||||||
# or
|
|
||||||
bun dev
|
## Technology Stack
|
||||||
|
|
||||||
|
- **Backend**: Symfony 7.2 (PHP 8.2+)
|
||||||
|
- **Database**: MongoDB with Doctrine ODM
|
||||||
|
- **Frontend**: Vanilla JavaScript with modern CSS
|
||||||
|
- **Architecture**: Clean Architecture with SOLID principles
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- PHP 8.2 or higher
|
||||||
|
- MongoDB 4.4 or higher
|
||||||
|
- Composer
|
||||||
|
- MongoDB PHP Extension
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. **Clone the repository**
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd evwiki
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Install dependencies**
|
||||||
|
```bash
|
||||||
|
composer install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Configure environment**
|
||||||
|
```bash
|
||||||
|
cp .env.local.example .env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env.local` and set your MongoDB connection:
|
||||||
|
```
|
||||||
|
APP_ENV=dev
|
||||||
|
APP_SECRET=your-secret-key-here
|
||||||
|
MONGODB_URI=mongodb://localhost:27017
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Start MongoDB**
|
||||||
|
Make sure MongoDB is running on your system.
|
||||||
|
|
||||||
|
5. **Seed the database**
|
||||||
|
```bash
|
||||||
|
php bin/console app:seed-data
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Start the development server**
|
||||||
|
```bash
|
||||||
|
symfony server:start
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use PHP's built-in server:
|
||||||
|
```bash
|
||||||
|
php -S localhost:8000 -t public/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── Command/ # Console commands
|
||||||
|
├── Controller/ # HTTP controllers
|
||||||
|
├── Document/ # MongoDB document models
|
||||||
|
├── Repository/ # Data access layer
|
||||||
|
├── Service/ # Business logic layer
|
||||||
|
└── Kernel.php # Application kernel
|
||||||
|
|
||||||
|
templates/
|
||||||
|
├── base.html.twig # Base template
|
||||||
|
└── home/ # Home page templates
|
||||||
|
|
||||||
|
config/
|
||||||
|
├── bundles.php # Bundle configuration
|
||||||
|
├── packages/ # Package configurations
|
||||||
|
├── routes.yaml # Route definitions
|
||||||
|
└── services.yaml # Service container
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
## API Endpoints
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
### Search
|
||||||
|
- `GET /api/search?query={term}` - Search for vehicles, brands, or models
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
### Brands
|
||||||
|
- `GET /api/brands` - Get all brands
|
||||||
|
- `GET /api/brands/{brandId}/models` - Get models for a specific brand
|
||||||
|
|
||||||
## Learn More
|
### Models
|
||||||
|
- `GET /api/models/category/{category}` - Get models by category
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
## Architecture
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
This application follows clean architecture principles:
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
### Domain Layer
|
||||||
|
- **Documents**: MongoDB document models (`Brand`, `CarModel`, `CarRevision`)
|
||||||
|
- **Repositories**: Data access interfaces
|
||||||
|
|
||||||
## Deploy on Vercel
|
### Application Layer
|
||||||
|
- **Services**: Business logic (`CarSearchService`)
|
||||||
|
- **Commands**: Console commands for data management
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
### Infrastructure Layer
|
||||||
|
- **Controllers**: HTTP request handlers
|
||||||
|
- **Templates**: Twig templates for rendering
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
### Key Design Principles
|
||||||
|
|
||||||
|
1. **SOLID Principles**: Each class has a single responsibility
|
||||||
|
2. **Dependency Injection**: All dependencies are injected via constructor
|
||||||
|
3. **No Else Statements**: Code uses early returns for better readability
|
||||||
|
4. **Readable Names**: Self-documenting code with descriptive names
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Adding New Data
|
||||||
|
|
||||||
|
Use the seed command to populate the database:
|
||||||
|
```bash
|
||||||
|
php bin/console app:seed-data
|
||||||
|
```
|
||||||
|
|
||||||
|
### Console Commands
|
||||||
|
|
||||||
|
List all available commands:
|
||||||
|
```bash
|
||||||
|
php bin/console list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Operations
|
||||||
|
|
||||||
|
The application uses MongoDB with Doctrine ODM. All database operations are handled through repositories following the Repository pattern.
|
||||||
|
|
||||||
|
## Styling Guidelines
|
||||||
|
|
||||||
|
- **No External CSS Frameworks**: Pure CSS following modern standards
|
||||||
|
- **Responsive Design**: Mobile-first approach
|
||||||
|
- **Modern UI**: Clean, minimalist design inspired by search engines
|
||||||
|
- **Accessibility**: Semantic HTML and proper contrast ratios
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Follow PSR-12 coding standards
|
||||||
|
2. Write clean, self-documenting code
|
||||||
|
3. Use dependency injection
|
||||||
|
4. Follow SOLID principles
|
||||||
|
5. Avoid else statements
|
||||||
|
6. Use meaningful variable and method names
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License.
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For support and questions, please open an issue in the repository.
|
||||||
|
|||||||
21
bin/console
Executable file
21
bin/console
Executable file
@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Kernel;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||||
|
|
||||||
|
if (!is_dir(dirname(__DIR__).'/vendor')) {
|
||||||
|
throw new LogicException('Dependencies are missing. Try running "composer install".');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
|
||||||
|
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
||||||
|
|
||||||
|
return function (array $context) {
|
||||||
|
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
||||||
|
|
||||||
|
return new Application($kernel);
|
||||||
|
};
|
||||||
58
composer.json
Normal file
58
composer.json
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"name": "evwiki/symfony-app",
|
||||||
|
"description": "Electric Vehicle Wiki - Symfony Application",
|
||||||
|
"type": "project",
|
||||||
|
"license": "MIT",
|
||||||
|
"minimum-stability": "stable",
|
||||||
|
"prefer-stable": true,
|
||||||
|
"require": {
|
||||||
|
"php": "^8.3",
|
||||||
|
"ext-ctype": "*",
|
||||||
|
"ext-iconv": "*",
|
||||||
|
"mongodb/mongodb": "*",
|
||||||
|
"symfony/console": "7.2.*",
|
||||||
|
"symfony/dotenv": "7.2.*",
|
||||||
|
"symfony/flex": "^2",
|
||||||
|
"symfony/framework-bundle": "7.2.*",
|
||||||
|
"symfony/runtime": "7.2.*",
|
||||||
|
"symfony/serializer": "7.2.*",
|
||||||
|
"symfony/twig-bundle": "7.2.*",
|
||||||
|
"symfony/validator": "7.2.*",
|
||||||
|
"symfony/yaml": "7.2.*"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"symfony/debug-bundle": "7.2.*",
|
||||||
|
"symfony/maker-bundle": "^1.0",
|
||||||
|
"symfony/var-dumper": "7.2.*"
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"allow-plugins": {
|
||||||
|
"php-http/discovery": true,
|
||||||
|
"symfony/flex": true,
|
||||||
|
"symfony/runtime": true
|
||||||
|
},
|
||||||
|
"sort-packages": true
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"App\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"App\\Tests\\": "tests/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"auto-scripts": {
|
||||||
|
"cache:clear": "symfony-cmd",
|
||||||
|
"assets:install %PUBLIC_DIR%": "symfony-cmd"
|
||||||
|
},
|
||||||
|
"post-install-cmd": [
|
||||||
|
"@auto-scripts"
|
||||||
|
],
|
||||||
|
"post-update-cmd": [
|
||||||
|
"@auto-scripts"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
3591
composer.lock
generated
Normal file
3591
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
8
config/bundles.php
Normal file
8
config/bundles.php
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
||||||
|
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
|
||||||
|
Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true],
|
||||||
|
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
|
||||||
|
];
|
||||||
19
config/packages/cache.yaml
Normal file
19
config/packages/cache.yaml
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
framework:
|
||||||
|
cache:
|
||||||
|
# Unique name of your app: used to compute stable namespaces for cache keys.
|
||||||
|
#prefix_seed: your_vendor_name/app_name
|
||||||
|
|
||||||
|
# The "app" cache stores to the filesystem by default.
|
||||||
|
# The data in this cache should persist between deploys.
|
||||||
|
# Other options include:
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
#app: cache.adapter.redis
|
||||||
|
#default_redis_provider: redis://localhost
|
||||||
|
|
||||||
|
# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
|
||||||
|
#app: cache.adapter.apcu
|
||||||
|
|
||||||
|
# Namespaced pools use the above "app" backend by default
|
||||||
|
#pools:
|
||||||
|
#my.dedicated.cache: null
|
||||||
5
config/packages/debug.yaml
Normal file
5
config/packages/debug.yaml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
when@dev:
|
||||||
|
debug:
|
||||||
|
# Forwards VarDumper Data clones to a centralized server allowing to inspect dumps on CLI or in your browser.
|
||||||
|
# See the "server:dump" command to start a new server.
|
||||||
|
dump_destination: "tcp://%env(VAR_DUMPER_SERVER)%"
|
||||||
10
config/packages/framework.yaml
Normal file
10
config/packages/framework.yaml
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
framework:
|
||||||
|
secret: '%env(APP_SECRET)%'
|
||||||
|
serializer:
|
||||||
|
enabled: true
|
||||||
|
validation:
|
||||||
|
enabled: true
|
||||||
|
http_method_override: false
|
||||||
|
handle_all_throwables: true
|
||||||
|
php_errors:
|
||||||
|
log: true
|
||||||
10
config/packages/routing.yaml
Normal file
10
config/packages/routing.yaml
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
framework:
|
||||||
|
router:
|
||||||
|
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
|
||||||
|
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
|
||||||
|
#default_uri: http://localhost
|
||||||
|
|
||||||
|
when@prod:
|
||||||
|
framework:
|
||||||
|
router:
|
||||||
|
strict_requirements: null
|
||||||
2
config/packages/twig.yaml
Normal file
2
config/packages/twig.yaml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
twig:
|
||||||
|
default_path: '%kernel.project_dir%/templates'
|
||||||
11
config/packages/validator.yaml
Normal file
11
config/packages/validator.yaml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
framework:
|
||||||
|
validation:
|
||||||
|
# Enables validator auto-mapping support.
|
||||||
|
# For instance, basic validation constraints will be inferred from Doctrine's metadata.
|
||||||
|
#auto_mapping:
|
||||||
|
# App\Entity\: []
|
||||||
|
|
||||||
|
when@test:
|
||||||
|
framework:
|
||||||
|
validation:
|
||||||
|
not_compromised_password: false
|
||||||
5
config/preload.php
Normal file
5
config/preload.php
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
if (file_exists(dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) {
|
||||||
|
require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';
|
||||||
|
}
|
||||||
5
config/routes.yaml
Normal file
5
config/routes.yaml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
controllers:
|
||||||
|
resource:
|
||||||
|
path: ../src/Application/Controller/
|
||||||
|
namespace: App\Application\Controller
|
||||||
|
type: attribute
|
||||||
4
config/routes/framework.yaml
Normal file
4
config/routes/framework.yaml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
when@dev:
|
||||||
|
_errors:
|
||||||
|
resource: '@FrameworkBundle/Resources/config/routing/errors.xml'
|
||||||
|
prefix: /_error
|
||||||
18
config/services.yaml
Normal file
18
config/services.yaml
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
parameters:
|
||||||
|
|
||||||
|
services:
|
||||||
|
_defaults:
|
||||||
|
autowire: true
|
||||||
|
autoconfigure: true
|
||||||
|
|
||||||
|
App\:
|
||||||
|
resource: '../src/'
|
||||||
|
exclude:
|
||||||
|
- '../src/DependencyInjection/'
|
||||||
|
- '../src/Document/'
|
||||||
|
- '../src/Kernel.php'
|
||||||
|
|
||||||
|
App\Infrastructure\MongoDB\MongoDBClient:
|
||||||
|
arguments:
|
||||||
|
$dsl: '%env(MONGODB_DSL)%'
|
||||||
|
$databaseName: '%env(MONGODB_DATABASE)%'
|
||||||
@ -8,7 +8,6 @@ services:
|
|||||||
hostname: evwiki.test
|
hostname: evwiki.test
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- /app/node_modules
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- mongodb
|
- mongodb
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
env: {
|
||||||
|
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
792
package-lock.json
generated
792
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -9,9 +9,12 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dotenvx/dotenvx": "^1.44.1",
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
|
"dotenv": "^16.5.0",
|
||||||
"mongodb": "^6.16.0",
|
"mongodb": "^6.16.0",
|
||||||
"next": "15.3.2",
|
"next": "15.3.2",
|
||||||
|
"openai": "^4.100.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0"
|
||||||
},
|
},
|
||||||
@ -22,6 +25,8 @@
|
|||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.3.2",
|
"eslint-config-next": "15.3.2",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 391 B |
@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.0 KiB |
9
public/index.php
Normal file
9
public/index.php
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Kernel;
|
||||||
|
|
||||||
|
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
||||||
|
|
||||||
|
return function (array $context) {
|
||||||
|
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
||||||
|
};
|
||||||
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 128 B |
@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 385 B |
24
src/Application/Controller/HomeController.php
Normal file
24
src/Application/Controller/HomeController.php
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Application\Controller;
|
||||||
|
|
||||||
|
use App\Domain\Repository\BrandRepository;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
class HomeController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly BrandRepository $brandRepository,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/', name: 'home')]
|
||||||
|
public function index(): Response
|
||||||
|
{
|
||||||
|
return $this->render('home/index.html.twig', [
|
||||||
|
'brands' => $this->brandRepository->findAll()->array(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/Application/Controller/SearchController.php
Normal file
27
src/Application/Controller/SearchController.php
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Application\Controller;
|
||||||
|
|
||||||
|
use App\Domain\Search\Engine;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
class SearchController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly Engine $engine,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/s/{query}', name: 'search')]
|
||||||
|
public function index(string $query): Response
|
||||||
|
{
|
||||||
|
$decodedQuery = urldecode(str_replace('+', ' ', $query));
|
||||||
|
|
||||||
|
return $this->render('result/index.html.twig', [
|
||||||
|
'tiles' => $this->engine->search($decodedQuery)->array(),
|
||||||
|
'query' => $decodedQuery,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/Application/Twig/InstanceOfTwigExtension.php
Normal file
23
src/Application/Twig/InstanceOfTwigExtension.php
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Application\Twig;
|
||||||
|
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
|
||||||
|
use Twig\Extension\AbstractExtension;
|
||||||
|
use Twig\TwigTest;
|
||||||
|
|
||||||
|
#[AutoconfigureTag('twig.extension')]
|
||||||
|
class InstanceOfTwigExtension extends AbstractExtension
|
||||||
|
{
|
||||||
|
public function getTests()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new TwigTest('instanceof', [$this, 'instanceOf']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function instanceOf(mixed $value, string $class): bool
|
||||||
|
{
|
||||||
|
return $value instanceof $class;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/Application/Twig/TileTwigName.php
Normal file
24
src/Application/Twig/TileTwigName.php
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Application\Twig;
|
||||||
|
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
|
||||||
|
use Twig\Extension\AbstractExtension;
|
||||||
|
use Twig\TwigFilter;
|
||||||
|
use Twig\TwigTest;
|
||||||
|
|
||||||
|
#[AutoconfigureTag('twig.extension')]
|
||||||
|
class TileTwigName extends AbstractExtension
|
||||||
|
{
|
||||||
|
public function getFilters()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new TwigFilter('twig_name', [$this, 'twigName']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function twigName(object $tile): string
|
||||||
|
{
|
||||||
|
return (new \ReflectionClass($tile))->getShortName();
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/Domain/Model/Brand.php
Normal file
17
src/Domain/Model/Brand.php
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Model;
|
||||||
|
|
||||||
|
class Brand
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public private(set) readonly string $id,
|
||||||
|
public private(set) readonly string $name,
|
||||||
|
public private(set) readonly string $logo,
|
||||||
|
public private(set) readonly string $description,
|
||||||
|
public private(set) readonly int $foundedYear,
|
||||||
|
public private(set) readonly string $headquarters,
|
||||||
|
public private(set) readonly string $website,
|
||||||
|
public private(set) readonly array $carModels,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
16
src/Domain/Model/BrandCollection.php
Normal file
16
src/Domain/Model/BrandCollection.php
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Model;
|
||||||
|
|
||||||
|
class BrandCollection
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly array $brands,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function array(): array
|
||||||
|
{
|
||||||
|
return $this->brands;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/Domain/Model/CarModel.php
Normal file
24
src/Domain/Model/CarModel.php
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Model;
|
||||||
|
|
||||||
|
class CarModel
|
||||||
|
{
|
||||||
|
private ?string $id = null;
|
||||||
|
|
||||||
|
private string $name;
|
||||||
|
|
||||||
|
private int $productionStartYear;
|
||||||
|
|
||||||
|
private ?int $productionEndYear = null;
|
||||||
|
|
||||||
|
private ?string $category = null;
|
||||||
|
|
||||||
|
private ?string $description = null;
|
||||||
|
|
||||||
|
private ?string $image = null;
|
||||||
|
|
||||||
|
private ?Brand $brand = null;
|
||||||
|
|
||||||
|
private array $revisions = [];
|
||||||
|
}
|
||||||
34
src/Domain/Model/CarRevision.php
Normal file
34
src/Domain/Model/CarRevision.php
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Model;
|
||||||
|
|
||||||
|
class CarRevision
|
||||||
|
{
|
||||||
|
private ?string $id = null;
|
||||||
|
|
||||||
|
private string $name;
|
||||||
|
|
||||||
|
private int $releaseYear;
|
||||||
|
|
||||||
|
private array $engineTypes = [];
|
||||||
|
|
||||||
|
private ?int $horsePower = null;
|
||||||
|
|
||||||
|
private ?int $torque = null;
|
||||||
|
|
||||||
|
private ?int $topSpeed = null;
|
||||||
|
|
||||||
|
private ?float $accelerationZeroToHundred = null;
|
||||||
|
|
||||||
|
private ?int $range = null;
|
||||||
|
|
||||||
|
private ?float $batteryCapacity = null;
|
||||||
|
|
||||||
|
private ?int $chargingTime = null;
|
||||||
|
|
||||||
|
private ?float $consumption = null;
|
||||||
|
|
||||||
|
private ?float $price = null;
|
||||||
|
|
||||||
|
private ?CarModel $carModel = null;
|
||||||
|
}
|
||||||
10
src/Domain/Repository/BrandRepository.php
Normal file
10
src/Domain/Repository/BrandRepository.php
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Repository;
|
||||||
|
|
||||||
|
use App\Domain\Model\BrandCollection;
|
||||||
|
|
||||||
|
interface BrandRepository
|
||||||
|
{
|
||||||
|
public function findAll(): BrandCollection;
|
||||||
|
}
|
||||||
98
src/Domain/Repository/CarModelRepository.php
Normal file
98
src/Domain/Repository/CarModelRepository.php
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Domain\Model\CarModel;
|
||||||
|
use Doctrine\ODM\MongoDB\DocumentManager;
|
||||||
|
use Doctrine\ODM\MongoDB\Repository\DocumentRepository;
|
||||||
|
|
||||||
|
class CarModelRepository extends DocumentRepository
|
||||||
|
{
|
||||||
|
public function __construct(DocumentManager $dm)
|
||||||
|
{
|
||||||
|
parent::__construct($dm, $dm->getUnitOfWork(), $dm->getClassMetadata(CarModel::class));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findAllCarModels(): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder()
|
||||||
|
->sort('name', 'asc')
|
||||||
|
->getQuery()
|
||||||
|
->execute()
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findCarModelById(string $id): ?CarModel
|
||||||
|
{
|
||||||
|
return $this->find($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findCarModelsByBrandId(string $brandId): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder()
|
||||||
|
->field('brand.$id')->equals(new \MongoDB\BSON\ObjectId($brandId))
|
||||||
|
->sort('name', 'asc')
|
||||||
|
->getQuery()
|
||||||
|
->execute()
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findCarModelsByName(string $name): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder()
|
||||||
|
->field('name')->equals(new \MongoDB\BSON\Regex($name, 'i'))
|
||||||
|
->sort('name', 'asc')
|
||||||
|
->getQuery()
|
||||||
|
->execute()
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findCarModelsByCategory(string $category): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder()
|
||||||
|
->field('category')->equals($category)
|
||||||
|
->sort('name', 'asc')
|
||||||
|
->getQuery()
|
||||||
|
->execute()
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findCarModelsByYearRange(int $startYear, int $endYear): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder()
|
||||||
|
->field('productionStartYear')->gte($startYear)
|
||||||
|
->field('productionStartYear')->lte($endYear)
|
||||||
|
->sort('productionStartYear', 'asc')
|
||||||
|
->getQuery()
|
||||||
|
->execute()
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function searchCarModels(string $query): array
|
||||||
|
{
|
||||||
|
$regex = new \MongoDB\BSON\Regex($query, 'i');
|
||||||
|
|
||||||
|
return $this->createQueryBuilder()
|
||||||
|
->addOr(
|
||||||
|
$this->createQueryBuilder()->field('name')->equals($regex)->getQueryArray(),
|
||||||
|
$this->createQueryBuilder()->field('description')->equals($regex)->getQueryArray(),
|
||||||
|
$this->createQueryBuilder()->field('category')->equals($regex)->getQueryArray()
|
||||||
|
)
|
||||||
|
->sort('name', 'asc')
|
||||||
|
->getQuery()
|
||||||
|
->execute()
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveCarModel(CarModel $carModel): void
|
||||||
|
{
|
||||||
|
$this->getDocumentManager()->persist($carModel);
|
||||||
|
$this->getDocumentManager()->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteCarModel(CarModel $carModel): void
|
||||||
|
{
|
||||||
|
$this->getDocumentManager()->remove($carModel);
|
||||||
|
$this->getDocumentManager()->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/Domain/Search/Engine.php
Normal file
18
src/Domain/Search/Engine.php
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Search;
|
||||||
|
|
||||||
|
use App\Domain\Search\Tiles\Section;
|
||||||
|
use App\Domain\Search\Tiles\Brand;
|
||||||
|
|
||||||
|
class Engine
|
||||||
|
{
|
||||||
|
public function search(string $query): TileCollection
|
||||||
|
{
|
||||||
|
return new TileCollection([
|
||||||
|
new Section('Hello', [
|
||||||
|
new Brand('Tesla', 'https://www.tesla.com/tesla_theme/assets/img/meta-tags/apple-touch-icon.png'),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/Domain/Search/TileCollection.php
Normal file
17
src/Domain/Search/TileCollection.php
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Search;
|
||||||
|
|
||||||
|
use App\Domain\Search\Tiles\Section;
|
||||||
|
|
||||||
|
class TileCollection
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly array $tiles,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function array(): array
|
||||||
|
{
|
||||||
|
return $this->tiles;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/Domain/Search/Tiles/Brand.php
Normal file
11
src/Domain/Search/Tiles/Brand.php
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Search\Tiles;
|
||||||
|
|
||||||
|
class Brand
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $name,
|
||||||
|
public readonly string $logo,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
11
src/Domain/Search/Tiles/Section.php
Normal file
11
src/Domain/Search/Tiles/Section.php
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Search\Tiles;
|
||||||
|
|
||||||
|
class Section
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $title,
|
||||||
|
public readonly array $tiles,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
22
src/Infrastructure/MongoDB/MongoDBClient.php
Normal file
22
src/Infrastructure/MongoDB/MongoDBClient.php
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Infrastructure\MongoDB;
|
||||||
|
|
||||||
|
use MongoDB\Client;
|
||||||
|
use MongoDB\Database;
|
||||||
|
|
||||||
|
class MongoDBClient
|
||||||
|
{
|
||||||
|
public private(set) Client $client;
|
||||||
|
|
||||||
|
public Database $database {
|
||||||
|
get => $this->client->selectDatabase($this->databaseName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $dsl,
|
||||||
|
private readonly string $databaseName,
|
||||||
|
) {
|
||||||
|
$this->client = new Client($this->dsl);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Infrastructure\MongoDB\Repository\BrandRepository;
|
||||||
|
|
||||||
|
use App\Domain\Model\Brand;
|
||||||
|
|
||||||
|
class ModelMapper
|
||||||
|
{
|
||||||
|
public function map(array $data): Brand
|
||||||
|
{
|
||||||
|
$id = $data['_id'] ?? null;
|
||||||
|
if ($id !== null) {
|
||||||
|
$id = (string) $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Brand(
|
||||||
|
id: $id,
|
||||||
|
name: $data['name'] ?? null,
|
||||||
|
logo: $data['logo'] ?? null,
|
||||||
|
description: $data['description'] ?? null,
|
||||||
|
foundedYear: $data['foundedYear'] ?? null,
|
||||||
|
headquarters: $data['headquarters'] ?? null,
|
||||||
|
website: $data['website'] ?? null,
|
||||||
|
carModels: $data['carModels'] ?? [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Infrastructure\MongoDB\Repository\BrandRepository;
|
||||||
|
|
||||||
|
use App\Domain\Model\BrandCollection;
|
||||||
|
use App\Domain\Repository\BrandRepository;
|
||||||
|
use App\Infrastructure\MongoDB\MongoDBClient;
|
||||||
|
|
||||||
|
final class MongoDBBrandRepository implements BrandRepository
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly MongoDBClient $client,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findAll(): BrandCollection
|
||||||
|
{
|
||||||
|
$result = $this->client->database->selectCollection('brands')->find();
|
||||||
|
$brands = [];
|
||||||
|
|
||||||
|
$mapper = new ModelMapper();
|
||||||
|
|
||||||
|
/** @var \MongoDB\Model\BSONDocument $brand */
|
||||||
|
foreach ($result as $brand) {
|
||||||
|
$brands[] = $mapper->map($brand->getArrayCopy());
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BrandCollection($brands);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/Kernel.php
Normal file
11
src/Kernel.php
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
|
||||||
|
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
|
||||||
|
|
||||||
|
class Kernel extends BaseKernel
|
||||||
|
{
|
||||||
|
use MicroKernelTrait;
|
||||||
|
}
|
||||||
@ -1,20 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { CarRepository } from '../../../../backend/repositories/CarRepository';
|
|
||||||
|
|
||||||
const carRepository = new CarRepository();
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const searchParams = request.nextUrl.searchParams;
|
|
||||||
const query = searchParams.get('query') || '';
|
|
||||||
try {
|
|
||||||
let results = await carRepository.searchCarsByName(query);
|
|
||||||
|
|
||||||
return NextResponse.json(results);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Search error:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to search cars' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,201 +0,0 @@
|
|||||||
/* Components CSS - Modern simple design with rounded corners */
|
|
||||||
|
|
||||||
/* Buttons */
|
|
||||||
.btn {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0.6rem 1.2rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
text-align: center;
|
|
||||||
border: none;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background-color: var(--color-yale-blue);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background-color: #0a4e96;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background-color: var(--color-cerise);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background-color: #e4547a;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline {
|
|
||||||
background-color: transparent;
|
|
||||||
color: var(--color-yale-blue);
|
|
||||||
border: 1px solid var(--color-yale-blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline:hover {
|
|
||||||
background-color: rgba(8, 61, 119, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Typography */
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.2;
|
|
||||||
color: var(--foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h5 {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h6 {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Links */
|
|
||||||
.link {
|
|
||||||
color: var(--color-yale-blue);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color 0.2s;
|
|
||||||
border-bottom: 1px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link:hover {
|
|
||||||
color: var(--color-cerise);
|
|
||||||
border-bottom: 1px solid var(--color-cerise);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form Elements */
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.6rem 0.8rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: var(--foreground);
|
|
||||||
background-color: #fff;
|
|
||||||
background-clip: padding-box;
|
|
||||||
border: 1px solid #ced4da;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input:focus {
|
|
||||||
color: var(--foreground);
|
|
||||||
background-color: #fff;
|
|
||||||
border-color: var(--color-yale-blue);
|
|
||||||
outline: 0;
|
|
||||||
box-shadow: 0 0 0 0.2rem rgba(8, 61, 119, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-select {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.6rem 2rem 0.6rem 0.8rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: var(--foreground);
|
|
||||||
background-color: #fff;
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23343a40' d='M6 8.5l4-4 1 1-5 5-5-5 1-1z'/%3E%3C/svg%3E");
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: right 0.8rem center;
|
|
||||||
background-size: 12px 12px;
|
|
||||||
border: 1px solid #ced4da;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
appearance: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-checkbox, .form-radio {
|
|
||||||
display: inline-block;
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Card */
|
|
||||||
.card {
|
|
||||||
background-color: white;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
|
||||||
padding: 1.5rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
border-bottom: 1px solid #efefef;
|
|
||||||
padding-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-title {
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-body {
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Badge */
|
|
||||||
.badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1;
|
|
||||||
text-align: center;
|
|
||||||
white-space: nowrap;
|
|
||||||
vertical-align: baseline;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-primary {
|
|
||||||
background-color: var(--color-yale-blue);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-secondary {
|
|
||||||
background-color: var(--color-cerise);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-light {
|
|
||||||
background-color: var(--color-naples-yellow);
|
|
||||||
color: var(--color-charcoal);
|
|
||||||
}
|
|
||||||
@ -1,111 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
const styles = {
|
|
||||||
brandSection: {
|
|
||||||
marginTop: '2rem',
|
|
||||||
textAlign: 'center' as const,
|
|
||||||
color: '#2e4057',
|
|
||||||
position: 'relative' as const
|
|
||||||
},
|
|
||||||
brandSectionBar: {
|
|
||||||
content: '',
|
|
||||||
position: 'absolute' as const,
|
|
||||||
top: '-15px',
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
width: '50px',
|
|
||||||
height: '3px',
|
|
||||||
backgroundColor: '#edae49',
|
|
||||||
borderRadius: '3px'
|
|
||||||
},
|
|
||||||
brandList: {
|
|
||||||
marginTop: '0.8rem',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
flexWrap: 'wrap' as const,
|
|
||||||
gap: '1.2rem'
|
|
||||||
},
|
|
||||||
brandLink: {
|
|
||||||
color: '#083d77',
|
|
||||||
textDecoration: 'none',
|
|
||||||
fontWeight: '500',
|
|
||||||
padding: '0.5rem 1rem',
|
|
||||||
borderRadius: '20px',
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
backgroundColor: 'white',
|
|
||||||
border: '1px solid rgba(8, 61, 119, 0.1)'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function BrandList() {
|
|
||||||
return (
|
|
||||||
<div style={styles.brandSection}>
|
|
||||||
<div style={styles.brandSectionBar}></div>
|
|
||||||
<p>Popular brands</p>
|
|
||||||
<div style={styles.brandList}>
|
|
||||||
<Link
|
|
||||||
href="/results?brand=1"
|
|
||||||
style={styles.brandLink}
|
|
||||||
onMouseOver={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = '#edae49';
|
|
||||||
e.currentTarget.style.color = '#2e4057';
|
|
||||||
}}
|
|
||||||
onMouseOut={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = 'white';
|
|
||||||
e.currentTarget.style.color = '#083d77';
|
|
||||||
}}
|
|
||||||
>Tesla</Link>
|
|
||||||
<Link
|
|
||||||
href="/results?brand=2"
|
|
||||||
style={styles.brandLink}
|
|
||||||
onMouseOver={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = '#edae49';
|
|
||||||
e.currentTarget.style.color = '#2e4057';
|
|
||||||
}}
|
|
||||||
onMouseOut={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = 'white';
|
|
||||||
e.currentTarget.style.color = '#083d77';
|
|
||||||
}}
|
|
||||||
>BMW</Link>
|
|
||||||
<Link
|
|
||||||
href="/results?brand=3"
|
|
||||||
style={styles.brandLink}
|
|
||||||
onMouseOver={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = '#edae49';
|
|
||||||
e.currentTarget.style.color = '#2e4057';
|
|
||||||
}}
|
|
||||||
onMouseOut={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = 'white';
|
|
||||||
e.currentTarget.style.color = '#083d77';
|
|
||||||
}}
|
|
||||||
>Toyota</Link>
|
|
||||||
<Link
|
|
||||||
href="/results?brand=4"
|
|
||||||
style={styles.brandLink}
|
|
||||||
onMouseOver={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = '#edae49';
|
|
||||||
e.currentTarget.style.color = '#2e4057';
|
|
||||||
}}
|
|
||||||
onMouseOut={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = 'white';
|
|
||||||
e.currentTarget.style.color = '#083d77';
|
|
||||||
}}
|
|
||||||
>Audi</Link>
|
|
||||||
<Link
|
|
||||||
href="/results?brand=5"
|
|
||||||
style={styles.brandLink}
|
|
||||||
onMouseOver={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = '#edae49';
|
|
||||||
e.currentTarget.style.color = '#2e4057';
|
|
||||||
}}
|
|
||||||
onMouseOut={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = 'white';
|
|
||||||
e.currentTarget.style.color = '#083d77';
|
|
||||||
}}
|
|
||||||
>Mercedes</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,103 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, FormEvent } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
type SearchBarProps = {
|
|
||||||
initialQuery?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function SearchBar({ initialQuery = '' }: SearchBarProps) {
|
|
||||||
const [query, setQuery] = useState(initialQuery);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const handleSubmit = (e: FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (query.trim()) {
|
|
||||||
router.push(`/results?query=${encodeURIComponent(query)}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = {
|
|
||||||
searchContainer: {
|
|
||||||
width: '100%',
|
|
||||||
maxWidth: '42rem',
|
|
||||||
margin: '0 auto 1.5rem'
|
|
||||||
},
|
|
||||||
searchForm: {
|
|
||||||
display: 'flex',
|
|
||||||
width: '100%',
|
|
||||||
position: 'relative'
|
|
||||||
},
|
|
||||||
searchInput: {
|
|
||||||
width: '100%',
|
|
||||||
padding: '1.2rem 1.5rem',
|
|
||||||
fontSize: '1.2rem',
|
|
||||||
border: '2px solid transparent',
|
|
||||||
borderRadius: '30px',
|
|
||||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
|
|
||||||
outline: 'none',
|
|
||||||
transition: 'all 0.3s ease',
|
|
||||||
backgroundColor: 'white',
|
|
||||||
color: '#2e4057'
|
|
||||||
},
|
|
||||||
searchButton: {
|
|
||||||
position: 'absolute',
|
|
||||||
right: '12px',
|
|
||||||
top: '50%',
|
|
||||||
transform: 'translateY(-50%)',
|
|
||||||
width: '45px',
|
|
||||||
height: '45px',
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
color: '#083d77',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '50%',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center'
|
|
||||||
}
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={styles.searchContainer}>
|
|
||||||
<form onSubmit={handleSubmit} style={styles.searchForm}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
placeholder="Search for electric vehicles..."
|
|
||||||
style={styles.searchInput}
|
|
||||||
aria-label="Search for electric vehicles"
|
|
||||||
onFocus={(e) => {
|
|
||||||
e.target.style.boxShadow = '0 6px 16px rgba(8, 61, 119, 0.2)';
|
|
||||||
e.target.style.borderColor = '#083d77';
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
e.target.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.1)';
|
|
||||||
e.target.style.borderColor = 'transparent';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
style={styles.searchButton}
|
|
||||||
aria-label="Search"
|
|
||||||
onMouseOver={(e) => {
|
|
||||||
e.currentTarget.style.color = '#2e4057';
|
|
||||||
e.currentTarget.style.transform = 'translateY(-50%) scale(1.05)';
|
|
||||||
}}
|
|
||||||
onMouseOut={(e) => {
|
|
||||||
e.currentTarget.style.color = '#083d77';
|
|
||||||
e.currentTarget.style.transform = 'translateY(-50%)';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
|
|
||||||
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
@ -1,50 +0,0 @@
|
|||||||
:root {
|
|
||||||
--color-sunset: #F6D8AE;
|
|
||||||
--color-charcoal: #2E4057;
|
|
||||||
--color-yale-blue: #083D77;
|
|
||||||
--color-cerise: #DA4167;
|
|
||||||
--color-naples-yellow: #F4D35E;
|
|
||||||
|
|
||||||
/* Neutral background colors */
|
|
||||||
--color-light-neutral: #F5F5F5;
|
|
||||||
--color-dark-neutral: #1F1F1F;
|
|
||||||
|
|
||||||
--background: var(--color-light-neutral);
|
|
||||||
--foreground: var(--color-charcoal);
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
max-width: 100vw;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background: var(--background);
|
|
||||||
color: var(--foreground);
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
html {
|
|
||||||
color-scheme: dark;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-primary {
|
|
||||||
background: var(--color-yale-blue);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
@ -1,180 +0,0 @@
|
|||||||
/* Home page specific styles */
|
|
||||||
.container {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 2rem;
|
|
||||||
background: linear-gradient(135deg, var(--background), rgba(8, 61, 119, 0.05));
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 3.5rem;
|
|
||||||
font-weight: 800;
|
|
||||||
margin-bottom: 2.5rem;
|
|
||||||
text-align: center;
|
|
||||||
letter-spacing: -1px;
|
|
||||||
animation: fadeIn 0.8s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gradientText {
|
|
||||||
background: linear-gradient(90deg,
|
|
||||||
var(--color-yale-blue) 0%,
|
|
||||||
var(--color-charcoal) 35%,
|
|
||||||
var(--color-cerise) 100%);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; transform: translateY(-20px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchContainer {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 42rem;
|
|
||||||
margin-bottom: 2.5rem;
|
|
||||||
animation: scaleIn 0.5s ease-in-out;
|
|
||||||
animation-delay: 0.3s;
|
|
||||||
animation-fill-mode: both;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes scaleIn {
|
|
||||||
from { opacity: 0; transform: scale(0.95); }
|
|
||||||
to { opacity: 1; transform: scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchForm {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchInput {
|
|
||||||
width: 100%;
|
|
||||||
padding: 1.2rem 1.5rem;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
border-radius: 30px;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
||||||
outline: none;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
background-color: white;
|
|
||||||
color: var(--color-charcoal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchInput:focus {
|
|
||||||
box-shadow: 0 6px 16px rgba(8, 61, 119, 0.2);
|
|
||||||
border-color: var(--color-yale-blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchInput::placeholder {
|
|
||||||
color: rgba(46, 64, 87, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchButton {
|
|
||||||
position: absolute;
|
|
||||||
right: 5px;
|
|
||||||
top: 5px;
|
|
||||||
bottom: 5px;
|
|
||||||
padding: 0 1.8rem;
|
|
||||||
background-color: var(--color-yale-blue);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 25px;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchButton:hover {
|
|
||||||
background-color: var(--color-charcoal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 42rem;
|
|
||||||
background-color: white;
|
|
||||||
padding: 2rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
color: var(--foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.yearGrid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brandSection {
|
|
||||||
margin-top: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--color-charcoal);
|
|
||||||
animation: fadeIn 0.5s ease-in-out;
|
|
||||||
animation-delay: 0.6s;
|
|
||||||
animation-fill-mode: both;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brandSection::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: -15px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
width: 50px;
|
|
||||||
height: 3px;
|
|
||||||
background-color: var(--color-naples-yellow);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brandSection p {
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 0.8rem;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brandList {
|
|
||||||
margin-top: 0.8rem;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brandLink {
|
|
||||||
color: var(--color-yale-blue);
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 20px;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
background-color: white;
|
|
||||||
border: 1px solid rgba(8, 61, 119, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.brandLink:hover {
|
|
||||||
background-color: var(--color-sunset);
|
|
||||||
color: var(--color-charcoal);
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
|
||||||
import "./globals.css";
|
|
||||||
import "./components.css";
|
|
||||||
|
|
||||||
const geistSans = Geist({
|
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "EV WIKI",
|
|
||||||
description: "Modern search engine for electric vehicle information",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RootLayout({
|
|
||||||
children,
|
|
||||||
}: Readonly<{
|
|
||||||
children: React.ReactNode;
|
|
||||||
}>) {
|
|
||||||
return (
|
|
||||||
<html lang="en">
|
|
||||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
|
||||||
{children}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,471 +0,0 @@
|
|||||||
.page {
|
|
||||||
--gray-rgb: 0, 0, 0;
|
|
||||||
--gray-alpha-200: rgba(var(--gray-rgb), 0.08);
|
|
||||||
--gray-alpha-100: rgba(var(--gray-rgb), 0.05);
|
|
||||||
|
|
||||||
--button-primary-hover: #383838;
|
|
||||||
--button-secondary-hover: #f2f2f2;
|
|
||||||
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: 20px 1fr 20px;
|
|
||||||
align-items: center;
|
|
||||||
justify-items: center;
|
|
||||||
min-height: 100svh;
|
|
||||||
padding: 80px;
|
|
||||||
gap: 64px;
|
|
||||||
font-family: var(--font-geist-sans);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.page {
|
|
||||||
--gray-rgb: 255, 255, 255;
|
|
||||||
--gray-alpha-200: rgba(var(--gray-rgb), 0.145);
|
|
||||||
--gray-alpha-100: rgba(var(--gray-rgb), 0.06);
|
|
||||||
|
|
||||||
--button-primary-hover: #ccc;
|
|
||||||
--button-secondary-hover: #1a1a1a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.main {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 32px;
|
|
||||||
grid-row-start: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main ol {
|
|
||||||
font-family: var(--font-geist-mono);
|
|
||||||
padding-left: 0;
|
|
||||||
margin: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 24px;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
list-style-position: inside;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main li:not(:last-of-type) {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main code {
|
|
||||||
font-family: inherit;
|
|
||||||
background: var(--gray-alpha-100);
|
|
||||||
padding: 2px 4px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ctas {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ctas a {
|
|
||||||
appearance: none;
|
|
||||||
border-radius: 128px;
|
|
||||||
height: 48px;
|
|
||||||
padding: 0 20px;
|
|
||||||
border: none;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
transition:
|
|
||||||
background 0.2s,
|
|
||||||
color 0.2s,
|
|
||||||
border-color 0.2s;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 20px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.primary {
|
|
||||||
background: var(--foreground);
|
|
||||||
color: var(--background);
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.secondary {
|
|
||||||
border-color: var(--gray-alpha-200);
|
|
||||||
min-width: 158px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
grid-row-start: 3;
|
|
||||||
display: flex;
|
|
||||||
gap: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer a {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer img {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enable hover only on non-touch devices */
|
|
||||||
@media (hover: hover) and (pointer: fine) {
|
|
||||||
a.primary:hover {
|
|
||||||
background: var(--button-primary-hover);
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.secondary:hover {
|
|
||||||
background: var(--button-secondary-hover);
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
text-underline-offset: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.page {
|
|
||||||
padding: 32px;
|
|
||||||
padding-bottom: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main {
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main ol {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ctas {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ctas a {
|
|
||||||
font-size: 14px;
|
|
||||||
height: 40px;
|
|
||||||
padding: 0 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.secondary {
|
|
||||||
min-width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.logo {
|
|
||||||
filter: invert();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchPage {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 100vh;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-family: var(--font-geist-sans);
|
|
||||||
background: var(--background);
|
|
||||||
color: var(--foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchMain {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex: 1;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 800px;
|
|
||||||
padding: 0 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logoContainer {
|
|
||||||
margin-bottom: 36px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
font-size: 4rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: -1px;
|
|
||||||
background: linear-gradient(to right, var(--color-yale-blue), var(--color-cerise));
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
background-clip: text;
|
|
||||||
color: transparent;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchContainer {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 600px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchBox {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
position: relative;
|
|
||||||
border-radius: 24px;
|
|
||||||
background: #fff;
|
|
||||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
|
||||||
overflow: hidden;
|
|
||||||
transition: box-shadow 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchBox:hover, .searchBox:focus-within {
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchInput {
|
|
||||||
flex: 1;
|
|
||||||
height: 48px;
|
|
||||||
padding: 0 16px;
|
|
||||||
font-size: 16px;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
background: transparent;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchButton {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0 16px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--color-yale-blue);
|
|
||||||
transition: color 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchButton:hover {
|
|
||||||
color: var(--color-cerise);
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchIcon {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.suggestionsContainer {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.suggestionChip {
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: rgba(8, 61, 119, 0.08);
|
|
||||||
border: 1px solid rgba(8, 61, 119, 0.15);
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--foreground);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.suggestionChip:hover {
|
|
||||||
background: var(--color-yale-blue);
|
|
||||||
color: white;
|
|
||||||
border-color: var(--color-yale-blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchFooter {
|
|
||||||
width: 100%;
|
|
||||||
padding: 16px 0;
|
|
||||||
border-top: 1px solid rgba(var(--gray-rgb), 0.1);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footerLinks {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footerLinks a {
|
|
||||||
color: var(--foreground);
|
|
||||||
opacity: 0.8;
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footerLinks a:hover {
|
|
||||||
opacity: 1;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.logo {
|
|
||||||
font-size: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchBox {
|
|
||||||
border-radius: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchInput {
|
|
||||||
height: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.suggestionsContainer {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.suggestionChip {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 280px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footerLinks {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 16px;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.searchBox {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchBox:hover, .searchBox:focus-within {
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchInput {
|
|
||||||
color: var(--foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchButton {
|
|
||||||
color: var(--color-yale-blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchButton:hover {
|
|
||||||
color: var(--color-cerise);
|
|
||||||
}
|
|
||||||
|
|
||||||
.suggestionChip {
|
|
||||||
background: rgba(255, 255, 255, 0.07);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.pageContainer {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 2rem;
|
|
||||||
background-color: #f9fafb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mainTitle {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchCard {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 40rem;
|
|
||||||
background-color: white;
|
|
||||||
padding: 2rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cardTitle {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.formGroup {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #4b5563;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input,
|
|
||||||
.select {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input:focus,
|
|
||||||
.select:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #3b82f6;
|
|
||||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.yearGrid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button {
|
|
||||||
width: 100%;
|
|
||||||
background-color: #3b82f6;
|
|
||||||
color: white;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button:hover {
|
|
||||||
background-color: #2563eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brandSection {
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
text-align: center;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brandList {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brandLink {
|
|
||||||
color: #3b82f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brandLink:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import SearchBar from './components/SearchBar';
|
|
||||||
import BrandList from './components/BrandList';
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
const styles = {
|
|
||||||
container: {
|
|
||||||
minHeight: '100vh',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: '2rem',
|
|
||||||
background: 'linear-gradient(135deg, #ffffff, rgba(8, 61, 119, 0.05))'
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: '3.5rem',
|
|
||||||
fontWeight: '800',
|
|
||||||
marginBottom: '2.5rem',
|
|
||||||
textAlign: 'center',
|
|
||||||
letterSpacing: '-1px',
|
|
||||||
background: 'linear-gradient(90deg, #083d77 0%, #2e4057 35%, #d1495b 100%)',
|
|
||||||
WebkitBackgroundClip: 'text',
|
|
||||||
backgroundClip: 'text',
|
|
||||||
WebkitTextFillColor: 'transparent',
|
|
||||||
display: 'inline-block'
|
|
||||||
}
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main style={styles.container}>
|
|
||||||
<h1 style={styles.title}>E-WIKI</h1>
|
|
||||||
<SearchBar />
|
|
||||||
<BrandList />
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,228 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
import { CarService } from '../services/carService';
|
|
||||||
import { CarModel, CarRevision, Brand } from '../../backend/models';
|
|
||||||
import SearchBar from '../components/SearchBar';
|
|
||||||
import styles from './results.module.css';
|
|
||||||
|
|
||||||
// Extended interfaces for the frontend display
|
|
||||||
interface DisplayCarModel extends CarModel {
|
|
||||||
brand?: {
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DisplayCarRevision extends CarRevision {
|
|
||||||
baseModel?: {
|
|
||||||
brand?: {
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Results() {
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [models, setModels] = useState<DisplayCarModel[]>([]);
|
|
||||||
const [revisions, setRevisions] = useState<DisplayCarRevision[]>([]);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const query = searchParams.get('query') || '';
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchResults = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const query = searchParams.get('query') || '';
|
|
||||||
const category = searchParams.get('category') || '';
|
|
||||||
const startYear = searchParams.get('startYear');
|
|
||||||
const endYear = searchParams.get('endYear');
|
|
||||||
const brandId = searchParams.get('brand');
|
|
||||||
|
|
||||||
let results;
|
|
||||||
|
|
||||||
if (brandId) {
|
|
||||||
// Handle brand-specific search (if implemented in CarService)
|
|
||||||
const response = await fetch(`/api/cars/brand/${brandId}`);
|
|
||||||
if (!response.ok) throw new Error('Failed to fetch brand models');
|
|
||||||
results = await response.json();
|
|
||||||
} else if (category) {
|
|
||||||
results = await CarService.searchByCategory(category);
|
|
||||||
} else if (startYear && endYear) {
|
|
||||||
results = await CarService.searchByYearRange(
|
|
||||||
parseInt(startYear),
|
|
||||||
parseInt(endYear)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
results = await CarService.searchByQuery(query);
|
|
||||||
}
|
|
||||||
|
|
||||||
setModels(results.models || []);
|
|
||||||
setRevisions(results.revisions || []);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching search results:', err);
|
|
||||||
setError('Failed to load search results. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchResults();
|
|
||||||
}, [searchParams]);
|
|
||||||
|
|
||||||
// Helper function to generate a unique key
|
|
||||||
const getModelKey = (model: DisplayCarModel) => {
|
|
||||||
return model._id?.toString() || model.id || `model-${model.name}-${model.productionStartYear}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getRevisionKey = (revision: DisplayCarRevision) => {
|
|
||||||
return revision._id?.toString() || revision.id || `revision-${revision.name}-${revision.releaseYear}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className={styles.container}>
|
|
||||||
<div className={styles.content}>
|
|
||||||
<SearchBar initialQuery={query} />
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className={styles.loader}>
|
|
||||||
<div className={styles.spinner}></div>
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className={styles.errorMessage}>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : models.length === 0 && revisions.length === 0 ? (
|
|
||||||
<div className={styles.emptyResults}>
|
|
||||||
<h2 className={styles.noResultsTitle}>No results found</h2>
|
|
||||||
<p className={styles.noResultsText}>
|
|
||||||
Try adjusting your search criteria to find more cars.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
{models.length > 0 && (
|
|
||||||
<div className={styles.sectionContent}>
|
|
||||||
<h2 className={styles.sectionTitle}>Car Models</h2>
|
|
||||||
<div className={styles.grid}>
|
|
||||||
{models.map((model) => (
|
|
||||||
<div
|
|
||||||
key={getModelKey(model)}
|
|
||||||
className={styles.card}
|
|
||||||
>
|
|
||||||
<div className={styles.cardImage}>
|
|
||||||
{model.image ? (
|
|
||||||
<img
|
|
||||||
src={model.image}
|
|
||||||
alt={model.name}
|
|
||||||
className={styles.image}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className={styles.noImage}>
|
|
||||||
No image available
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className={styles.cardContent}>
|
|
||||||
<div className={styles.cardHeader}>
|
|
||||||
<h3 className={styles.cardTitle}>{model.name}</h3>
|
|
||||||
<span className={styles.brandName}>
|
|
||||||
{model.brand?.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className={styles.cardDescription}>{model.description}</p>
|
|
||||||
<div className={styles.cardFooter}>
|
|
||||||
<span className={styles.category}>
|
|
||||||
{model.category}
|
|
||||||
</span>
|
|
||||||
<span className={styles.years}>
|
|
||||||
Since {model.productionStartYear}
|
|
||||||
{model.productionEndYear
|
|
||||||
? ` - ${model.productionEndYear}`
|
|
||||||
: ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{revisions.length > 0 && (
|
|
||||||
<div className={styles.sectionContent}>
|
|
||||||
<h2 className={styles.sectionTitle}>Car Revisions</h2>
|
|
||||||
<div className={styles.grid}>
|
|
||||||
{revisions.map((revision) => (
|
|
||||||
<div
|
|
||||||
key={getRevisionKey(revision)}
|
|
||||||
className={styles.card}
|
|
||||||
>
|
|
||||||
<div className={styles.cardImage}>
|
|
||||||
{revision.images && revision.images.length > 0 ? (
|
|
||||||
<img
|
|
||||||
src={revision.images[0]}
|
|
||||||
alt={revision.name}
|
|
||||||
className={styles.image}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className={styles.noImage}>
|
|
||||||
No image available
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className={styles.cardContent}>
|
|
||||||
<div className={styles.cardHeader}>
|
|
||||||
<h3 className={styles.cardTitle}>{revision.name}</h3>
|
|
||||||
<span className={styles.brandName}>
|
|
||||||
{revision.baseModel?.brand?.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.specGrid}>
|
|
||||||
<div>
|
|
||||||
<span className={styles.specLabel}>Engine: </span>
|
|
||||||
{revision.engineTypes?.join(', ')}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className={styles.specLabel}>Power: </span>
|
|
||||||
{revision.horsePower} HP
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className={styles.specLabel}>0-100 km/h: </span>
|
|
||||||
{revision.acceleration0To100}s
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className={styles.specLabel}>Top Speed: </span>
|
|
||||||
{revision.topSpeed} km/h
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.tagContainer}>
|
|
||||||
{revision.features?.slice(0, 3).map((feature, index) => (
|
|
||||||
<span
|
|
||||||
key={`${getRevisionKey(revision)}-feature-${index}`}
|
|
||||||
className={styles.tag}
|
|
||||||
>
|
|
||||||
{feature}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{revision.features && revision.features.length > 3 && (
|
|
||||||
<span className={styles.tagCount}>
|
|
||||||
+{revision.features.length - 3} more
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,238 +0,0 @@
|
|||||||
/* Results page specific styles */
|
|
||||||
.container {
|
|
||||||
min-height: 100vh;
|
|
||||||
background: linear-gradient(135deg, var(--background), rgba(8, 61, 119, 0.05));
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-charcoal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.backButton {
|
|
||||||
background-color: var(--color-yale-blue);
|
|
||||||
color: white;
|
|
||||||
padding: 0.6rem 1.2rem;
|
|
||||||
border-radius: 25px;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 600;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.backButton:hover {
|
|
||||||
background-color: var(--color-charcoal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loader {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
padding: 3rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
width: 3rem;
|
|
||||||
height: 3rem;
|
|
||||||
border: 0.25rem solid rgba(8, 61, 119, 0.1);
|
|
||||||
border-top: 0.25rem solid var(--color-yale-blue);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
0% { transform: rotate(0deg); }
|
|
||||||
100% { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.errorMessage {
|
|
||||||
background-color: #fde8e8;
|
|
||||||
border: 1px solid #f8b4b4;
|
|
||||||
color: #9b1c1c;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.noResults {
|
|
||||||
background-color: white;
|
|
||||||
padding: 2rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emptyResults {
|
|
||||||
text-align: center;
|
|
||||||
padding: 2rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.noResultsTitle {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
color: var(--color-charcoal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.noResultsText {
|
|
||||||
color: #4b5563;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionTitle {
|
|
||||||
font-size: 1.8rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
color: var(--color-charcoal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionContent {
|
|
||||||
margin-bottom: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(1, 1fr);
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.grid {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.grid {
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background-color: white;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
||||||
overflow: hidden;
|
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover {
|
|
||||||
transform: translateY(-4px);
|
|
||||||
box-shadow: 0 10px 15px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cardImage {
|
|
||||||
height: 12rem;
|
|
||||||
background-color: #f3f4f6;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.noImage {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cardContent {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cardHeader {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cardTitle {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-charcoal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.brandName {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-yale-blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cardDescription {
|
|
||||||
color: #4b5563;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cardFooter {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category {
|
|
||||||
background-color: #f3f4f6;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
color: #4b5563;
|
|
||||||
}
|
|
||||||
|
|
||||||
.years {
|
|
||||||
color: #4b5563;
|
|
||||||
}
|
|
||||||
|
|
||||||
.specGrid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.specLabel {
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-charcoal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tagContainer {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.25rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag {
|
|
||||||
background-color: #dbeafe;
|
|
||||||
color: #1e40af;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tagCount {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
import { Brand, CarModel, CarRevision } from '../../backend/models';
|
|
||||||
|
|
||||||
interface SearchResults {
|
|
||||||
models: CarModel[];
|
|
||||||
revisions: CarRevision[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CarService {
|
|
||||||
/**
|
|
||||||
* Search cars by free-text query
|
|
||||||
*/
|
|
||||||
static async searchByQuery(query: string): Promise<SearchResults> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/cars/search?query=${encodeURIComponent(query)}`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to search cars');
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error searching cars:', error);
|
|
||||||
return { models: [], revisions: [] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search cars by category
|
|
||||||
*/
|
|
||||||
static async searchByCategory(category: string): Promise<SearchResults> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/cars/search?category=${encodeURIComponent(category)}`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to search cars by category');
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error searching cars by category:', error);
|
|
||||||
return { models: [], revisions: [] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search cars by year range
|
|
||||||
*/
|
|
||||||
static async searchByYearRange(startYear: number, endYear: number): Promise<SearchResults> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/cars/search?startYear=${startYear}&endYear=${endYear}`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to search cars by year range');
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error searching cars by year range:', error);
|
|
||||||
return { models: [], revisions: [] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all car brands
|
|
||||||
*/
|
|
||||||
static async getAllBrands(): Promise<Brand[]> {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/cars/brands');
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to fetch brands');
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching brands:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
import { CarModel } from './CarModel';
|
|
||||||
import { ObjectId } from 'mongodb';
|
|
||||||
|
|
||||||
export interface Brand {
|
|
||||||
_id?: ObjectId;
|
|
||||||
id?: string;
|
|
||||||
name: string;
|
|
||||||
logo: string;
|
|
||||||
description: string;
|
|
||||||
foundedYear: number;
|
|
||||||
headquarters: string;
|
|
||||||
website: string;
|
|
||||||
carModels?: CarModel[];
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
import { Brand } from './Brand';
|
|
||||||
import { CarRevision } from './CarRevision';
|
|
||||||
import { ObjectId } from 'mongodb';
|
|
||||||
|
|
||||||
export interface CarModel {
|
|
||||||
_id?: ObjectId;
|
|
||||||
id?: string;
|
|
||||||
name: string;
|
|
||||||
productionStartYear: number;
|
|
||||||
productionEndYear?: number;
|
|
||||||
category?: string;
|
|
||||||
description?: string;
|
|
||||||
image?: string;
|
|
||||||
revisions?: CarRevision[];
|
|
||||||
brandId?: ObjectId;
|
|
||||||
}
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
import { CarModel } from './CarModel';
|
|
||||||
import { ObjectId } from 'mongodb';
|
|
||||||
|
|
||||||
export interface Dimensions {
|
|
||||||
length: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
wheelbase: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CarRevision {
|
|
||||||
_id?: ObjectId;
|
|
||||||
id?: string;
|
|
||||||
name: string;
|
|
||||||
releaseYear: number;
|
|
||||||
engineTypes: string[];
|
|
||||||
horsePower: number;
|
|
||||||
torque: number;
|
|
||||||
topSpeed: number;
|
|
||||||
acceleration0To100: number;
|
|
||||||
fuelConsumption: number;
|
|
||||||
dimensions: Dimensions;
|
|
||||||
weight: number;
|
|
||||||
features: string[];
|
|
||||||
images: string[];
|
|
||||||
modelId?: ObjectId;
|
|
||||||
}
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
export * from './Brand';
|
|
||||||
export * from './CarModel';
|
|
||||||
export * from './CarRevision';
|
|
||||||
@ -1,540 +0,0 @@
|
|||||||
import { Brand, CarModel, CarRevision } from '../models';
|
|
||||||
import { MongoClient, Db, Collection, ObjectId } from 'mongodb';
|
|
||||||
|
|
||||||
export class CarRepository {
|
|
||||||
private client: MongoClient;
|
|
||||||
private db: Db | null = null;
|
|
||||||
private brandsCollection: Collection<Brand> | null = null;
|
|
||||||
private carModelsCollection: Collection<CarModel> | null = null;
|
|
||||||
private carRevisionsCollection: Collection<CarRevision> | null = null;
|
|
||||||
|
|
||||||
constructor(private mongoUrl: string = process.env.MONGODB_URI || 'mongodb://localhost:27017/evwiki') {
|
|
||||||
this.client = new MongoClient(this.mongoUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the database connection
|
|
||||||
*/
|
|
||||||
async connect(): Promise<void> {
|
|
||||||
if (!this.db) {
|
|
||||||
await this.client.connect();
|
|
||||||
this.db = this.client.db();
|
|
||||||
this.brandsCollection = this.db.collection<Brand>('brands');
|
|
||||||
this.carModelsCollection = this.db.collection<CarModel>('carModels');
|
|
||||||
this.carRevisionsCollection = this.db.collection<CarRevision>('carRevisions');
|
|
||||||
|
|
||||||
// Check if data exists, if not initialize with default data
|
|
||||||
const brandsCount = await this.brandsCollection.countDocuments();
|
|
||||||
if (brandsCount === 0) {
|
|
||||||
await this.initializeDefaultData();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close the database connection
|
|
||||||
*/
|
|
||||||
async disconnect(): Promise<void> {
|
|
||||||
if (this.client) {
|
|
||||||
await this.client.close();
|
|
||||||
this.db = null;
|
|
||||||
this.brandsCollection = null;
|
|
||||||
this.carModelsCollection = null;
|
|
||||||
this.carRevisionsCollection = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the database with default data if empty
|
|
||||||
*/
|
|
||||||
private async initializeDefaultData(): Promise<void> {
|
|
||||||
if (!this.db || !this.brandsCollection || !this.carModelsCollection || !this.carRevisionsCollection) {
|
|
||||||
throw new Error('Database not initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert default brands
|
|
||||||
await this.brandsCollection.insertMany([
|
|
||||||
{
|
|
||||||
name: 'Tesla',
|
|
||||||
logo: 'https://example.com/tesla-logo.png',
|
|
||||||
description: 'American electric vehicle and clean energy company',
|
|
||||||
foundedYear: 2003,
|
|
||||||
headquarters: 'Palo Alto, California, United States',
|
|
||||||
website: 'https://www.tesla.com',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'BMW',
|
|
||||||
logo: 'https://example.com/bmw-logo.png',
|
|
||||||
description: 'German luxury automobile and motorcycle manufacturer',
|
|
||||||
foundedYear: 1916,
|
|
||||||
headquarters: 'Munich, Germany',
|
|
||||||
website: 'https://www.bmw.com',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Toyota',
|
|
||||||
logo: 'https://example.com/toyota-logo.png',
|
|
||||||
description: 'Japanese multinational automotive manufacturer',
|
|
||||||
foundedYear: 1937,
|
|
||||||
headquarters: 'Toyota City, Japan',
|
|
||||||
website: 'https://www.toyota.com',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Audi',
|
|
||||||
logo: 'https://example.com/audi-logo.png',
|
|
||||||
description: 'German luxury automobile manufacturer',
|
|
||||||
foundedYear: 1909,
|
|
||||||
headquarters: 'Ingolstadt, Germany',
|
|
||||||
website: 'https://www.audi.com',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Mercedes-Benz',
|
|
||||||
logo: 'https://example.com/mercedes-logo.png',
|
|
||||||
description: 'German global automobile marque and a division of Daimler AG',
|
|
||||||
foundedYear: 1926,
|
|
||||||
headquarters: 'Stuttgart, Germany',
|
|
||||||
website: 'https://www.mercedes-benz.com',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Get the inserted brands to connect them with models later
|
|
||||||
const brands = await this.brandsCollection.find().toArray();
|
|
||||||
|
|
||||||
// Insert default car models
|
|
||||||
await this.carModelsCollection.insertMany([
|
|
||||||
{
|
|
||||||
name: 'Model S',
|
|
||||||
productionStartYear: 2012,
|
|
||||||
category: 'Sedan',
|
|
||||||
description: 'All-electric five-door liftback sedan',
|
|
||||||
image: 'https://example.com/tesla-model-s.jpg',
|
|
||||||
brandId: brands[0]._id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Model 3',
|
|
||||||
productionStartYear: 2017,
|
|
||||||
category: 'Sedan',
|
|
||||||
description: 'All-electric four-door sedan',
|
|
||||||
image: 'https://example.com/tesla-model-3.jpg',
|
|
||||||
brandId: brands[0]._id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '3 Series',
|
|
||||||
productionStartYear: 1975,
|
|
||||||
category: 'Sedan',
|
|
||||||
description: 'Compact executive car',
|
|
||||||
image: 'https://example.com/bmw-3-series.jpg',
|
|
||||||
brandId: brands[1]._id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'X5',
|
|
||||||
productionStartYear: 1999,
|
|
||||||
category: 'SUV',
|
|
||||||
description: 'Mid-size luxury SUV',
|
|
||||||
image: 'https://example.com/bmw-x5.jpg',
|
|
||||||
brandId: brands[1]._id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Camry',
|
|
||||||
productionStartYear: 1982,
|
|
||||||
category: 'Sedan',
|
|
||||||
description: 'Mid-size car',
|
|
||||||
image: 'https://example.com/toyota-camry.jpg',
|
|
||||||
brandId: brands[2]._id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Prius',
|
|
||||||
productionStartYear: 1997,
|
|
||||||
category: 'Hatchback',
|
|
||||||
description: 'Hybrid electric mid-size car',
|
|
||||||
image: 'https://example.com/toyota-prius.jpg',
|
|
||||||
brandId: brands[2]._id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'A4',
|
|
||||||
productionStartYear: 1994,
|
|
||||||
category: 'Sedan',
|
|
||||||
description: 'Compact executive car',
|
|
||||||
image: 'https://example.com/audi-a4.jpg',
|
|
||||||
brandId: brands[3]._id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Q7',
|
|
||||||
productionStartYear: 2005,
|
|
||||||
category: 'SUV',
|
|
||||||
description: 'Full-size luxury crossover SUV',
|
|
||||||
image: 'https://example.com/audi-q7.jpg',
|
|
||||||
brandId: brands[3]._id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'C-Class',
|
|
||||||
productionStartYear: 1993,
|
|
||||||
category: 'Sedan',
|
|
||||||
description: 'Compact executive car',
|
|
||||||
image: 'https://example.com/mercedes-c-class.jpg',
|
|
||||||
brandId: brands[4]._id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'GLE',
|
|
||||||
productionStartYear: 2015,
|
|
||||||
category: 'SUV',
|
|
||||||
description: 'Mid-size luxury crossover SUV',
|
|
||||||
image: 'https://example.com/mercedes-gle.jpg',
|
|
||||||
brandId: brands[4]._id,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Get the inserted models to connect them with revisions
|
|
||||||
const models = await this.carModelsCollection.find().toArray();
|
|
||||||
|
|
||||||
// Find models by name for easy reference
|
|
||||||
const findModelByName = (name: string) => models.find(model => model.name === name);
|
|
||||||
|
|
||||||
// Insert default car revisions
|
|
||||||
await this.carRevisionsCollection.insertMany([
|
|
||||||
{
|
|
||||||
name: 'Model S Long Range Plus',
|
|
||||||
releaseYear: 2020,
|
|
||||||
engineTypes: ['Electric'],
|
|
||||||
horsePower: 670,
|
|
||||||
torque: 850,
|
|
||||||
topSpeed: 250,
|
|
||||||
acceleration0To100: 3.1,
|
|
||||||
fuelConsumption: 0,
|
|
||||||
dimensions: {
|
|
||||||
length: 4970,
|
|
||||||
width: 1964,
|
|
||||||
height: 1445,
|
|
||||||
wheelbase: 2960,
|
|
||||||
},
|
|
||||||
weight: 2250,
|
|
||||||
features: ['Autopilot', 'Premium Interior', 'All-Wheel Drive'],
|
|
||||||
images: ['https://example.com/tesla-model-s-2020-1.jpg', 'https://example.com/tesla-model-s-2020-2.jpg'],
|
|
||||||
modelId: findModelByName('Model S')?._id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Model S Plaid',
|
|
||||||
releaseYear: 2021,
|
|
||||||
engineTypes: ['Electric'],
|
|
||||||
horsePower: 1020,
|
|
||||||
torque: 1050,
|
|
||||||
topSpeed: 322,
|
|
||||||
acceleration0To100: 2.1,
|
|
||||||
fuelConsumption: 0,
|
|
||||||
dimensions: {
|
|
||||||
length: 4970,
|
|
||||||
width: 1964,
|
|
||||||
height: 1445,
|
|
||||||
wheelbase: 2960,
|
|
||||||
},
|
|
||||||
weight: 2300,
|
|
||||||
features: ['Enhanced Autopilot', 'Yoke Steering', 'All-Wheel Drive', 'New Interior Design'],
|
|
||||||
images: ['https://example.com/tesla-model-s-plaid-1.jpg', 'https://example.com/tesla-model-s-plaid-2.jpg'],
|
|
||||||
modelId: findModelByName('Model S')?._id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Model 3 Standard Range Plus',
|
|
||||||
releaseYear: 2021,
|
|
||||||
engineTypes: ['Electric'],
|
|
||||||
horsePower: 283,
|
|
||||||
torque: 450,
|
|
||||||
topSpeed: 225,
|
|
||||||
acceleration0To100: 5.6,
|
|
||||||
fuelConsumption: 0,
|
|
||||||
dimensions: {
|
|
||||||
length: 4694,
|
|
||||||
width: 1849,
|
|
||||||
height: 1443,
|
|
||||||
wheelbase: 2875,
|
|
||||||
},
|
|
||||||
weight: 1750,
|
|
||||||
features: ['Basic Autopilot', 'Standard Interior', 'Rear-Wheel Drive'],
|
|
||||||
images: ['https://example.com/tesla-model-3-standard-1.jpg', 'https://example.com/tesla-model-3-standard-2.jpg'],
|
|
||||||
modelId: findModelByName('Model 3')?._id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '330i Sedan',
|
|
||||||
releaseYear: 2021,
|
|
||||||
engineTypes: ['Gasoline'],
|
|
||||||
horsePower: 255,
|
|
||||||
torque: 400,
|
|
||||||
topSpeed: 209,
|
|
||||||
acceleration0To100: 5.6,
|
|
||||||
fuelConsumption: 7.1,
|
|
||||||
dimensions: {
|
|
||||||
length: 4709,
|
|
||||||
width: 1827,
|
|
||||||
height: 1435,
|
|
||||||
wheelbase: 2851,
|
|
||||||
},
|
|
||||||
weight: 1620,
|
|
||||||
features: ['LED Headlights', 'iDrive Infotainment System', 'Leather Seats'],
|
|
||||||
images: ['https://example.com/bmw-330i-1.jpg', 'https://example.com/bmw-330i-2.jpg'],
|
|
||||||
modelId: findModelByName('3 Series')?._id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'M340i Sedan',
|
|
||||||
releaseYear: 2021,
|
|
||||||
engineTypes: ['Gasoline'],
|
|
||||||
horsePower: 382,
|
|
||||||
torque: 500,
|
|
||||||
topSpeed: 250,
|
|
||||||
acceleration0To100: 4.4,
|
|
||||||
fuelConsumption: 8.0,
|
|
||||||
dimensions: {
|
|
||||||
length: 4709,
|
|
||||||
width: 1827,
|
|
||||||
height: 1435,
|
|
||||||
wheelbase: 2851,
|
|
||||||
},
|
|
||||||
weight: 1670,
|
|
||||||
features: ['M Sport Differential', 'M Sport Brakes', 'Adaptive M Suspension'],
|
|
||||||
images: ['https://example.com/bmw-m340i-1.jpg', 'https://example.com/bmw-m340i-2.jpg'],
|
|
||||||
modelId: findModelByName('3 Series')?._id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'X5 xDrive40i',
|
|
||||||
releaseYear: 2021,
|
|
||||||
engineTypes: ['Gasoline'],
|
|
||||||
horsePower: 335,
|
|
||||||
torque: 450,
|
|
||||||
topSpeed: 243,
|
|
||||||
acceleration0To100: 5.5,
|
|
||||||
fuelConsumption: 9.2,
|
|
||||||
dimensions: {
|
|
||||||
length: 4922,
|
|
||||||
width: 2004,
|
|
||||||
height: 1745,
|
|
||||||
wheelbase: 2975,
|
|
||||||
},
|
|
||||||
weight: 2260,
|
|
||||||
features: ['Panoramic Roof', 'Head-Up Display', 'Gesture Control'],
|
|
||||||
images: ['https://example.com/bmw-x5-xdrive40i-1.jpg', 'https://example.com/bmw-x5-xdrive40i-2.jpg'],
|
|
||||||
modelId: findModelByName('X5')?._id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Camry LE',
|
|
||||||
releaseYear: 2021,
|
|
||||||
engineTypes: ['Gasoline'],
|
|
||||||
horsePower: 203,
|
|
||||||
torque: 250,
|
|
||||||
topSpeed: 210,
|
|
||||||
acceleration0To100: 8.1,
|
|
||||||
fuelConsumption: 7.6,
|
|
||||||
dimensions: {
|
|
||||||
length: 4880,
|
|
||||||
width: 1840,
|
|
||||||
height: 1445,
|
|
||||||
wheelbase: 2825,
|
|
||||||
},
|
|
||||||
weight: 1580,
|
|
||||||
features: ['Toyota Safety Sense', 'Apple CarPlay', 'Android Auto'],
|
|
||||||
images: ['https://example.com/toyota-camry-le-1.jpg', 'https://example.com/toyota-camry-le-2.jpg'],
|
|
||||||
modelId: findModelByName('Camry')?._id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Camry Hybrid',
|
|
||||||
releaseYear: 2021,
|
|
||||||
engineTypes: ['Hybrid'],
|
|
||||||
horsePower: 208,
|
|
||||||
torque: 220,
|
|
||||||
topSpeed: 180,
|
|
||||||
acceleration0To100: 7.8,
|
|
||||||
fuelConsumption: 4.2,
|
|
||||||
dimensions: {
|
|
||||||
length: 4880,
|
|
||||||
width: 1840,
|
|
||||||
height: 1445,
|
|
||||||
wheelbase: 2825,
|
|
||||||
},
|
|
||||||
weight: 1680,
|
|
||||||
features: ['Regenerative Braking', 'EV Mode', 'Energy Monitor'],
|
|
||||||
images: ['https://example.com/toyota-camry-hybrid-1.jpg', 'https://example.com/toyota-camry-hybrid-2.jpg'],
|
|
||||||
modelId: findModelByName('Camry')?._id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Prius Prime',
|
|
||||||
releaseYear: 2021,
|
|
||||||
engineTypes: ['Plug-in Hybrid'],
|
|
||||||
horsePower: 121,
|
|
||||||
torque: 142,
|
|
||||||
topSpeed: 165,
|
|
||||||
acceleration0To100: 10.5,
|
|
||||||
fuelConsumption: 1.8,
|
|
||||||
dimensions: {
|
|
||||||
length: 4645,
|
|
||||||
width: 1760,
|
|
||||||
height: 1470,
|
|
||||||
wheelbase: 2700,
|
|
||||||
},
|
|
||||||
weight: 1530,
|
|
||||||
features: ['Electric Range of 40 km', 'Touch-sensitive controls', 'Quad-LED projector headlights'],
|
|
||||||
images: ['https://example.com/toyota-prius-prime-1.jpg', 'https://example.com/toyota-prius-prime-2.jpg'],
|
|
||||||
modelId: findModelByName('Prius')?._id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'A4 Prestige',
|
|
||||||
releaseYear: 2021,
|
|
||||||
engineTypes: ['Gasoline'],
|
|
||||||
horsePower: 261,
|
|
||||||
torque: 370,
|
|
||||||
topSpeed: 210,
|
|
||||||
acceleration0To100: 5.5,
|
|
||||||
fuelConsumption: 7.5,
|
|
||||||
dimensions: {
|
|
||||||
length: 4762,
|
|
||||||
width: 1847,
|
|
||||||
height: 1435,
|
|
||||||
wheelbase: 2820,
|
|
||||||
},
|
|
||||||
weight: 1640,
|
|
||||||
features: ['Audi Virtual Cockpit', 'Bang & Olufsen Sound System', 'Adaptive Cruise Control'],
|
|
||||||
images: ['https://example.com/audi-a4-prestige-1.jpg', 'https://example.com/audi-a4-prestige-2.jpg'],
|
|
||||||
modelId: findModelByName('A4')?._id,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all brands
|
|
||||||
*/
|
|
||||||
async getAllBrands(): Promise<Brand[]> {
|
|
||||||
await this.connect();
|
|
||||||
if (!this.brandsCollection) throw new Error('Database not initialized');
|
|
||||||
|
|
||||||
return this.brandsCollection.find().toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get brand by ID
|
|
||||||
*/
|
|
||||||
async getBrandById(id: string): Promise<Brand | null> {
|
|
||||||
await this.connect();
|
|
||||||
if (!this.brandsCollection) throw new Error('Database not initialized');
|
|
||||||
|
|
||||||
return this.brandsCollection.findOne({ _id: new ObjectId(id) });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all car models
|
|
||||||
*/
|
|
||||||
async getAllCarModels(): Promise<CarModel[]> {
|
|
||||||
await this.connect();
|
|
||||||
if (!this.carModelsCollection) throw new Error('Database not initialized');
|
|
||||||
|
|
||||||
return this.carModelsCollection.find().toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get car models by brand ID
|
|
||||||
*/
|
|
||||||
async getCarModelsByBrandId(brandId: string): Promise<CarModel[]> {
|
|
||||||
await this.connect();
|
|
||||||
if (!this.carModelsCollection) throw new Error('Database not initialized');
|
|
||||||
|
|
||||||
return this.carModelsCollection.find({ brandId: new ObjectId(brandId) }).toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get car model by ID
|
|
||||||
*/
|
|
||||||
async getCarModelById(id: string): Promise<CarModel | null> {
|
|
||||||
await this.connect();
|
|
||||||
if (!this.carModelsCollection) throw new Error('Database not initialized');
|
|
||||||
|
|
||||||
return this.carModelsCollection.findOne({ _id: new ObjectId(id) });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all car revisions
|
|
||||||
*/
|
|
||||||
async getAllCarRevisions(): Promise<CarRevision[]> {
|
|
||||||
await this.connect();
|
|
||||||
if (!this.carRevisionsCollection) throw new Error('Database not initialized');
|
|
||||||
|
|
||||||
return this.carRevisionsCollection.find().toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get car revisions by model ID
|
|
||||||
*/
|
|
||||||
async getCarRevisionsByModelId(modelId: string): Promise<CarRevision[]> {
|
|
||||||
await this.connect();
|
|
||||||
if (!this.carRevisionsCollection) throw new Error('Database not initialized');
|
|
||||||
|
|
||||||
return this.carRevisionsCollection.find({ modelId: new ObjectId(modelId) }).toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get car revision by ID
|
|
||||||
*/
|
|
||||||
async getCarRevisionById(id: string): Promise<CarRevision | null> {
|
|
||||||
await this.connect();
|
|
||||||
if (!this.carRevisionsCollection) throw new Error('Database not initialized');
|
|
||||||
|
|
||||||
return this.carRevisionsCollection.findOne({ _id: new ObjectId(id) });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search cars by name (searches both models and revisions)
|
|
||||||
*/
|
|
||||||
async searchCarsByName(name: string): Promise<{ models: CarModel[], revisions: CarRevision[], brands: Brand[] }> {
|
|
||||||
await this.connect();
|
|
||||||
if (!this.brandsCollection || !this.carModelsCollection || !this.carRevisionsCollection) {
|
|
||||||
throw new Error('Database not initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
const lowercaseName = name.toLowerCase();
|
|
||||||
|
|
||||||
const matchingModels = await this.carModelsCollection.find({
|
|
||||||
name: { $regex: new RegExp(lowercaseName, 'i') }
|
|
||||||
}).toArray();
|
|
||||||
|
|
||||||
const matchingRevisions = await this.carRevisionsCollection.find({
|
|
||||||
name: { $regex: new RegExp(lowercaseName, 'i') }
|
|
||||||
}).toArray();
|
|
||||||
|
|
||||||
const matchingBrands = await this.brandsCollection.find({
|
|
||||||
name: { $regex: new RegExp(lowercaseName, 'i') }
|
|
||||||
}).toArray();
|
|
||||||
|
|
||||||
return {
|
|
||||||
models: matchingModels,
|
|
||||||
revisions: matchingRevisions,
|
|
||||||
brands: matchingBrands
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cars by category
|
|
||||||
*/
|
|
||||||
async getCarsByCategory(category: string): Promise<CarModel[]> {
|
|
||||||
await this.connect();
|
|
||||||
if (!this.carModelsCollection) throw new Error('Database not initialized');
|
|
||||||
|
|
||||||
return this.carModelsCollection.find({
|
|
||||||
category: { $regex: new RegExp(`^${category}$`, 'i') }
|
|
||||||
}).toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cars by year range
|
|
||||||
*/
|
|
||||||
async getCarsByYearRange(startYear: number, endYear: number): Promise<CarModel[]> {
|
|
||||||
await this.connect();
|
|
||||||
if (!this.carModelsCollection) throw new Error('Database not initialized');
|
|
||||||
|
|
||||||
return this.carModelsCollection.find({
|
|
||||||
$or: [
|
|
||||||
{ productionStartYear: { $lte: endYear, $gte: startYear } },
|
|
||||||
{
|
|
||||||
productionStartYear: { $lte: endYear },
|
|
||||||
productionEndYear: { $gte: startYear }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
productionStartYear: { $lte: endYear },
|
|
||||||
productionEndYear: { $exists: false }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}).toArray();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export * from './CarRepository';
|
|
||||||
105
symfony.lock
Normal file
105
symfony.lock
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
{
|
||||||
|
"symfony/console": {
|
||||||
|
"version": "7.2",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "5.3",
|
||||||
|
"ref": "1781ff40d8a17d87cf53f8d4cf0c8346ed2bb461"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"bin/console"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"symfony/debug-bundle": {
|
||||||
|
"version": "7.2",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "5.3",
|
||||||
|
"ref": "5aa8aa48234c8eb6dbdd7b3cd5d791485d2cec4b"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/debug.yaml"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"symfony/flex": {
|
||||||
|
"version": "2.7",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "2.4",
|
||||||
|
"ref": "52e9754527a15e2b79d9a610f98185a1fe46622a"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
".env",
|
||||||
|
".env.dev"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"symfony/framework-bundle": {
|
||||||
|
"version": "7.2",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "7.2",
|
||||||
|
"ref": "87bcf6f7c55201f345d8895deda46d2adbdbaa89"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/cache.yaml",
|
||||||
|
"config/packages/framework.yaml",
|
||||||
|
"config/preload.php",
|
||||||
|
"config/routes/framework.yaml",
|
||||||
|
"config/services.yaml",
|
||||||
|
"public/index.php",
|
||||||
|
"src/Controller/.gitignore",
|
||||||
|
"src/Kernel.php"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"symfony/maker-bundle": {
|
||||||
|
"version": "1.63",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "1.0",
|
||||||
|
"ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"symfony/routing": {
|
||||||
|
"version": "7.2",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "7.0",
|
||||||
|
"ref": "21b72649d5622d8f7da329ffb5afb232a023619d"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/routing.yaml",
|
||||||
|
"config/routes.yaml"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"symfony/twig-bundle": {
|
||||||
|
"version": "7.2",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "6.4",
|
||||||
|
"ref": "cab5fd2a13a45c266d45a7d9337e28dee6272877"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/twig.yaml",
|
||||||
|
"templates/base.html.twig"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"symfony/validator": {
|
||||||
|
"version": "7.2",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "7.0",
|
||||||
|
"ref": "8c1c4e28d26a124b0bb273f537ca8ce443472bfd"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/validator.yaml"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
45
templates/_components/search.html.twig
Normal file
45
templates/_components/search.html.twig
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
<div class="search-container" id="searchForm">
|
||||||
|
<input type="text" id="searchInput" class="search-input" placeholder="Search for electric vehicles, brands, or models..." value="{{ query|default('') }}">
|
||||||
|
<button type="button" id="searchButton" class="search-button">Search</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const searchForm = document.getElementById('searchForm');
|
||||||
|
const searchInput = document.getElementById('searchInput');
|
||||||
|
|
||||||
|
function encodeSearchQuery(query) {
|
||||||
|
query = query.replace(/[^a-zA-Z0-9+\-\s]/g, '');
|
||||||
|
return encodeURIComponent(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
searchForm.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const query = searchInput.value.trim();
|
||||||
|
if (query) {
|
||||||
|
const encodedQuery = encodeSearchQuery(query);
|
||||||
|
window.location.href = `/s/${encodedQuery}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
searchButton.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const query = searchInput.value.trim();
|
||||||
|
if (query) {
|
||||||
|
const encodedQuery = encodeSearchQuery(query);
|
||||||
|
window.location.href = `/s/${encodedQuery}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
searchInput.addEventListener('keypress', function(e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const query = searchInput.value.trim();
|
||||||
|
if (query) {
|
||||||
|
const encodedQuery = encodeSearchQuery(query);
|
||||||
|
window.location.href = `/s/${encodedQuery}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
162
templates/base.html.twig
Normal file
162
templates/base.html.twig
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}E-WIKI - Electric Vehicle Database{% endblock %}</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
background: linear-gradient(135deg, #ffffff, rgba(8, 61, 119, 0.05));
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 3.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
background: linear-gradient(90deg, #083d77 0%, #2e4057 35%, #d1495b 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto 3rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 50px;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
border-color: #083d77;
|
||||||
|
box-shadow: 0 4px 20px rgba(8, 61, 119, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-button {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: #083d77;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 25px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-button:hover {
|
||||||
|
background: #2e4057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile {
|
||||||
|
display: flex;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-title {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #083d77;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-container > * {
|
||||||
|
flex-grow: 0;
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-container {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #083d77;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-bottom: 2px solid #e0e0e0;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #999;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.title {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brands-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% block stylesheets %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
{% block body %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% block javascripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
31
templates/home/index.html.twig
Normal file
31
templates/home/index.html.twig
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}E-WIKI - Electric Vehicle Database{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="header">
|
||||||
|
<h1 class="title">E-WIKI</h1>
|
||||||
|
|
||||||
|
{% include '_components/search.html.twig' %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="brandsSection">
|
||||||
|
<h2 class="section-title">Popular Electric Vehicle Brands</h2>
|
||||||
|
<div class="brands-grid">
|
||||||
|
{% for brand in brands %}
|
||||||
|
<div class="brand-card" data-brand-id="{{ brand.id }}">
|
||||||
|
<div class="brand-name">{{ brand.name }}</div>
|
||||||
|
{% if brand.description %}
|
||||||
|
<div class="brand-description">{{ brand.description }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="brand-year">Founded: {{ brand.foundedYear }}</div>
|
||||||
|
{% if brand.headquarters %}
|
||||||
|
<div class="brand-year">{{ brand.headquarters }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="no-results">No brands available</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
11
templates/result/index.html.twig
Normal file
11
templates/result/index.html.twig
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="header">
|
||||||
|
<span class="title">E-WIKI</span>
|
||||||
|
|
||||||
|
{% include '_components/search.html.twig' with { query: query } %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include 'result/tiles/collection.html.twig' with { tiles: tiles } %}
|
||||||
|
{% endblock %}
|
||||||
4
templates/result/tiles/brand.html.twig
Normal file
4
templates/result/tiles/brand.html.twig
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<div class="tile">
|
||||||
|
<img src="{{ tile.logo }}" alt="{{ tile.name }}" class="tile-logo">
|
||||||
|
<div class="tile-title">{{ tile.name }}</div>
|
||||||
|
</div>
|
||||||
5
templates/result/tiles/collection.html.twig
Normal file
5
templates/result/tiles/collection.html.twig
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{% for tile in tiles %}
|
||||||
|
<div class="tile-container">
|
||||||
|
{% include 'result/tiles/' ~ tile|twig_name ~ '.html.twig' with { tile: tile } %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
3
templates/result/tiles/section.html.twig
Normal file
3
templates/result/tiles/section.html.twig
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<h1>{{ tile.title }}</h1>
|
||||||
|
|
||||||
|
{% include 'result/tiles/collection.html.twig' with { tiles: tile.tiles } %}
|
||||||
Loading…
x
Reference in New Issue
Block a user