Add openai integration

This commit is contained in:
Tim Lappe 2025-05-20 07:52:46 +02:00
parent 9cf1a287a7
commit a331d1a8da
74 changed files with 5547 additions and 2461 deletions

View File

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

View File

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

@ -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 ###

View File

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

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

File diff suppressed because it is too large Load Diff

8
config/bundles.php Normal file
View 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],
];

View 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

View 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)%"

View 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

View 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

View File

@ -0,0 +1,2 @@
twig:
default_path: '%kernel.project_dir%/templates'

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

@ -0,0 +1,5 @@
controllers:
resource:
path: ../src/Application/Controller/
namespace: App\Application\Controller
type: attribute

View File

@ -0,0 +1,4 @@
when@dev:
_errors:
resource: '@FrameworkBundle/Resources/config/routing/errors.xml'
prefix: /_error

18
config/services.yaml Normal file
View 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)%'

View File

@ -8,7 +8,6 @@ services:
hostname: evwiki.test
volumes:
- .:/app
- /app/node_modules
depends_on:
- mongodb
environment:

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -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
View 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']);
};

View File

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

View File

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

View File

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

View 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(),
]);
}
}

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

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

View 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();
}
}

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

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

View 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 = [];
}

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

View File

@ -0,0 +1,10 @@
<?php
namespace App\Domain\Repository;
use App\Domain\Model\BrandCollection;
interface BrandRepository
{
public function findAll(): BrandCollection;
}

View 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();
}
}

View 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'),
]),
]);
}
}

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

View File

@ -0,0 +1,11 @@
<?php
namespace App\Domain\Search\Tiles;
class Brand
{
public function __construct(
public readonly string $name,
public readonly string $logo,
) {}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Domain\Search\Tiles;
class Section
{
public function __construct(
public readonly string $title,
public readonly array $tiles,
) {}
}

View 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);
}
}

View File

@ -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'] ?? [],
);
}
}

View File

@ -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
View 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;
}

View File

@ -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 }
);
}
}

View File

@ -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);
}

View File

@ -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>
);
}

View File

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

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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;
}

View File

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

View File

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

View File

@ -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;
}

View File

@ -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;
}

View File

@ -1,3 +0,0 @@
export * from './Brand';
export * from './CarModel';
export * from './CarRevision';

View File

@ -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();
}
}

View File

@ -1 +0,0 @@
export * from './CarRepository';

105
symfony.lock Normal file
View 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"
]
}
}

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

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

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

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

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

View File

@ -0,0 +1,3 @@
<h1>{{ tile.title }}</h1>
{% include 'result/tiles/collection.html.twig' with { tiles: tile.tiles } %}