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
|
||||
*.tsbuildinfo
|
||||
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
|
||||
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json ./
|
||||
# Install system dependencies including zsh
|
||||
RUN apk add --no-cache \
|
||||
git \
|
||||
curl \
|
||||
libpng-dev \
|
||||
libxml2-dev \
|
||||
zip \
|
||||
unzip \
|
||||
openssl-dev \
|
||||
autoconf \
|
||||
g++ \
|
||||
make \
|
||||
zsh \
|
||||
wget
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
# Install PHP extensions
|
||||
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 ["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
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
- **Modern Search Interface**: Google-like search experience for electric vehicles
|
||||
- **Brand Directory**: Browse popular electric vehicle manufacturers
|
||||
- **Vehicle Database**: Comprehensive information about electric car models and revisions
|
||||
- **RESTful API**: JSON API endpoints for integration
|
||||
- **MongoDB Integration**: NoSQL database for flexible data storage
|
||||
- **Responsive Design**: Mobile-friendly interface without external CSS frameworks
|
||||
|
||||
## 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.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
This application follows clean architecture principles:
|
||||
|
||||
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
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
depends_on:
|
||||
- mongodb
|
||||
environment:
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
env: {
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||
},
|
||||
};
|
||||
|
||||
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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dotenvx/dotenvx": "^1.44.1",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"dotenv": "^16.5.0",
|
||||
"mongodb": "^6.16.0",
|
||||
"next": "15.3.2",
|
||||
"openai": "^4.100.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
@ -22,6 +25,8 @@
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.3.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"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