diff --git a/composer.json b/composer.json index 2fc0bba..62ce4a9 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ "symfony/dotenv": "7.2.*", "symfony/flex": "^2", "symfony/framework-bundle": "7.2.*", + "symfony/monolog-bundle": "^3.10", "symfony/runtime": "7.2.*", "symfony/serializer": "7.2.*", "symfony/twig-bundle": "7.2.*", diff --git a/composer.lock b/composer.lock index 67e5338..286cd77 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e4da23c3811aae55314b0e018026605a", + "content-hash": "45fe64d8d59b093a4dc302486a3a607d", "packages": [ { "name": "composer/semver", @@ -792,6 +792,109 @@ }, "time": "2025-01-24T11:45:48+00:00" }, + { + "name": "monolog/monolog", + "version": "3.9.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/10d85740180ecba7896c87e06a166e0c95a0e3b6", + "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.9.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2025-03-24T10:02:05+00:00" + }, { "name": "nyholm/psr7", "version": "1.8.2", @@ -3157,6 +3260,165 @@ ], "time": "2025-05-29T07:47:32+00:00" }, + { + "name": "symfony/monolog-bridge", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/monolog-bridge.git", + "reference": "1b188c8abbbef25b111da878797514b7a8d33990" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/1b188c8abbbef25b111da878797514b7a8d33990", + "reference": "1b188c8abbbef25b111da878797514b7a8d33990", + "shasum": "" + }, + "require": { + "monolog/monolog": "^3", + "php": ">=8.2", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/console": "<6.4", + "symfony/http-foundation": "<6.4", + "symfony/security-core": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0", + "symfony/http-client": "^6.4|^7.0", + "symfony/mailer": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/mime": "^6.4|^7.0", + "symfony/security-core": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\Monolog\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides integration for Monolog with various Symfony components", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/monolog-bridge/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-03-21T12:17:46+00:00" + }, + { + "name": "symfony/monolog-bundle", + "version": "v3.10.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/monolog-bundle.git", + "reference": "414f951743f4aa1fd0f5bf6a0e9c16af3fe7f181" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/414f951743f4aa1fd0f5bf6a0e9c16af3fe7f181", + "reference": "414f951743f4aa1fd0f5bf6a0e9c16af3fe7f181", + "shasum": "" + }, + "require": { + "monolog/monolog": "^1.25.1 || ^2.0 || ^3.0", + "php": ">=7.2.5", + "symfony/config": "^5.4 || ^6.0 || ^7.0", + "symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0", + "symfony/http-kernel": "^5.4 || ^6.0 || ^7.0", + "symfony/monolog-bridge": "^5.4 || ^6.0 || ^7.0" + }, + "require-dev": { + "symfony/console": "^5.4 || ^6.0 || ^7.0", + "symfony/phpunit-bridge": "^6.3 || ^7.0", + "symfony/yaml": "^5.4 || ^6.0 || ^7.0" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Bundle\\MonologBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony MonologBundle", + "homepage": "https://symfony.com", + "keywords": [ + "log", + "logging" + ], + "support": { + "issues": "https://github.com/symfony/monolog-bundle/issues", + "source": "https://github.com/symfony/monolog-bundle/tree/v3.10.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-11-06T17:08:13+00:00" + }, { "name": "symfony/polyfill-ctype", "version": "v1.32.0", @@ -4572,91 +4834,6 @@ ], "time": "2025-05-15T09:04:05+00:00" }, - { - "name": "symfony/web-profiler-bundle", - "version": "v7.3.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/web-profiler-bundle.git", - "reference": "a22b7e4a744820a56f1bafa830f2c72a2ba0913c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/a22b7e4a744820a56f1bafa830f2c72a2ba0913c", - "reference": "a22b7e4a744820a56f1bafa830f2c72a2ba0913c", - "shasum": "" - }, - "require": { - "composer-runtime-api": ">=2.1", - "php": ">=8.2", - "symfony/config": "^7.3", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/framework-bundle": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/routing": "^6.4|^7.0", - "symfony/twig-bundle": "^6.4|^7.0", - "twig/twig": "^3.12" - }, - "conflict": { - "symfony/form": "<6.4", - "symfony/mailer": "<6.4", - "symfony/messenger": "<6.4", - "symfony/serializer": "<7.2", - "symfony/workflow": "<7.3" - }, - "require-dev": { - "symfony/browser-kit": "^6.4|^7.0", - "symfony/console": "^6.4|^7.0", - "symfony/css-selector": "^6.4|^7.0", - "symfony/stopwatch": "^6.4|^7.0" - }, - "type": "symfony-bundle", - "autoload": { - "psr-4": { - "Symfony\\Bundle\\WebProfilerBundle\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides a development tool that gives detailed information about the execution of any request", - "homepage": "https://symfony.com", - "keywords": [ - "dev" - ], - "support": { - "source": "https://github.com/symfony/web-profiler-bundle/tree/v7.3.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2025-05-02T05:30:54+00:00" - }, { "name": "symfony/yaml", "version": "v7.2.6", @@ -5314,11 +5491,96 @@ } ], "time": "2025-04-17T09:11:12+00:00" + }, + { + "name": "symfony/web-profiler-bundle", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/web-profiler-bundle.git", + "reference": "a22b7e4a744820a56f1bafa830f2c72a2ba0913c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/a22b7e4a744820a56f1bafa830f2c72a2ba0913c", + "reference": "a22b7e4a744820a56f1bafa830f2c72a2ba0913c", + "shasum": "" + }, + "require": { + "composer-runtime-api": ">=2.1", + "php": ">=8.2", + "symfony/config": "^7.3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/framework-bundle": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/routing": "^6.4|^7.0", + "symfony/twig-bundle": "^6.4|^7.0", + "twig/twig": "^3.12" + }, + "conflict": { + "symfony/form": "<6.4", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/serializer": "<7.2", + "symfony/workflow": "<7.3" + }, + "require-dev": { + "symfony/browser-kit": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0", + "symfony/css-selector": "^6.4|^7.0", + "symfony/stopwatch": "^6.4|^7.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\WebProfilerBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a development tool that gives detailed information about the execution of any request", + "homepage": "https://symfony.com", + "keywords": [ + "dev" + ], + "support": { + "source": "https://github.com/symfony/web-profiler-bundle/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-05-02T05:30:54+00:00" } ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { @@ -5328,6 +5590,6 @@ "ext-pdo": "*", "ext-pgsql": "*" }, - "platform-dev": [], + "platform-dev": {}, "plugin-api-version": "2.6.0" } diff --git a/config/bundles.php b/config/bundles.php index c488ad8..f47c68c 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -8,4 +8,5 @@ return [ Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], + Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], ]; diff --git a/config/packages/monolog.yaml b/config/packages/monolog.yaml new file mode 100644 index 0000000..9db7d8a --- /dev/null +++ b/config/packages/monolog.yaml @@ -0,0 +1,62 @@ +monolog: + channels: + - deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists + +when@dev: + monolog: + handlers: + main: + type: stream + path: "%kernel.logs_dir%/%kernel.environment%.log" + level: debug + channels: ["!event"] + # uncomment to get logging in your browser + # you may have to allow bigger header sizes in your Web server configuration + #firephp: + # type: firephp + # level: info + #chromephp: + # type: chromephp + # level: info + console: + type: console + process_psr_3_messages: false + channels: ["!event", "!doctrine", "!console"] + +when@test: + monolog: + handlers: + main: + type: fingers_crossed + action_level: error + handler: nested + excluded_http_codes: [404, 405] + channels: ["!event"] + nested: + type: stream + path: "%kernel.logs_dir%/%kernel.environment%.log" + level: debug + +when@prod: + monolog: + handlers: + main: + type: fingers_crossed + action_level: error + handler: nested + excluded_http_codes: [404, 405] + buffer_size: 50 # How many messages should be saved? Prevent memory leaks + nested: + type: stream + path: php://stderr + level: debug + formatter: monolog.formatter.json + console: + type: console + process_psr_3_messages: false + channels: ["!event", "!doctrine"] + deprecation: + type: stream + channels: [deprecation] + path: php://stderr + formatter: monolog.formatter.json diff --git a/migrations/Version20250609160739.php b/migrations/Version20250609160739.php new file mode 100644 index 0000000..357d753 --- /dev/null +++ b/migrations/Version20250609160739.php @@ -0,0 +1,30 @@ +addSql('ALTER TABLE car_revisions ADD COLUMN embeddings JSONB'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + + } +} diff --git a/src/Application/Commands/LoadFixtures.php b/src/Application/Commands/LoadFixtures.php index 2d8105c..bdd7072 100644 --- a/src/Application/Commands/LoadFixtures.php +++ b/src/Application/Commands/LoadFixtures.php @@ -3,6 +3,7 @@ namespace App\Application\Commands; use App\Domain\ContentManagement\CarPropertyEmbedder; +use App\Domain\ContentManagement\CarRevisionEmbedder; use App\Domain\Model\Cars\Brand; use App\Domain\Model\Cars\CarModel; use App\Domain\Model\Cars\CarRevision; @@ -62,6 +63,7 @@ class LoadFixtures extends Command private readonly CarRevisionRepository $carRevisionRepository, private readonly CarPropertyRepository $carPropertyRepository, private readonly CarPropertyEmbedder $carPropertyEmbedder, + private readonly CarRevisionEmbedder $carRevisionEmbedder, ) { parent::__construct(); } @@ -154,6 +156,7 @@ class LoadFixtures extends Command $image ); + $this->carRevisionEmbedder->createPersistedEmbedding($carRevision, $carModel, $brand); $this->carRevisionRepository->save($carRevision); foreach ($revisionFixture['properties'] as $propertyValue) { @@ -399,6 +402,22 @@ class LoadFixtures extends Command new RangeSpecification(new NefzRange(new Range(614)), new WltpRange(new Range(602))), new CatalogPrice(new Price(129990, Currency::euro())), ] + ], + [ + 'revision' => 'Standard Range', + 'image' => new Image(externalPublicUrl: 'https://digitalassets.tesla.com/tesla-contents/image/upload/f_auto,q_auto/Model-3-Main-Hero-Desktop-LHD.jpg'), + 'properties' => [ + new Production(productionBegin: new Date(1, 1, 2020)), + new MotorPower(new Power(220)), + new Acceleration(6.7), + new TopSpeed(new Speed(150)), + new AverageConsumption(new Consumption(new Energy(15.5))), + new BatteryCapacity(new Energy(60.0), new Energy(66.0)), + new BatteryType(CellChemistry::LithiumNickelManganeseOxide, '2170', 'Tesla'), + new ChargingSpeed(new Power(120), new Power(120)), + new RangeSpecification(new NefzRange(new Range(450)), new WltpRange(new Range(450))), + new CatalogPrice(new Price(49990, Currency::euro())), + ] ] ] ] diff --git a/src/Application/Controller/SearchController.php b/src/Application/Controller/SearchController.php index 25b294f..ea10353 100644 --- a/src/Application/Controller/SearchController.php +++ b/src/Application/Controller/SearchController.php @@ -17,11 +17,21 @@ class SearchController extends AbstractController #[Route('/s/{query}', name: 'search')] public function index(string $query): Response { - $decodedQuery = urldecode(str_replace('+', ' ', $query)); + $decodedQuery = urldecode($query); return $this->render('result/index.html.twig', [ 'tiles' => $this->engine->search($decodedQuery)->array(), 'query' => $decodedQuery, ]); } + + #[Route('/result/{query}', name: 'result')] + public function result(string $query): Response + { + $decodedQuery = urldecode($query); + + return $this->render('_components/result.html.twig', [ + 'tiles' => $this->engine->search($decodedQuery)->array(), + ]); + } } \ No newline at end of file diff --git a/src/Domain/AI/AIClient.php b/src/Domain/AI/AIClient.php index 9ad6088..eece900 100644 --- a/src/Domain/AI/AIClient.php +++ b/src/Domain/AI/AIClient.php @@ -45,7 +45,7 @@ class AIClient public function generateJson(string $prompt): array { $response = $this->client->chat()->create([ - 'model' => 'gpt-4.1-nano', + 'model' => 'gpt-4.1-mini', 'messages' => [ ['role' => 'user', 'content' => $prompt], ], diff --git a/src/Domain/ContentManagement/CarPropertyEmbedder.php b/src/Domain/ContentManagement/CarPropertyEmbedder.php index 2f1ff40..fca3d40 100644 --- a/src/Domain/ContentManagement/CarPropertyEmbedder.php +++ b/src/Domain/ContentManagement/CarPropertyEmbedder.php @@ -12,6 +12,7 @@ use App\Domain\Model\Cars\CarRevision; use App\Domain\Model\Id\EmbeddingId; use App\Domain\Repository\EmbeddingRepository; + class CarPropertyEmbedder { public function __construct( @@ -20,6 +21,13 @@ class CarPropertyEmbedder ) { } + /** + * @param CarProperty $carProperty + * @param CarRevision|null $carRevision + * @param CarModel|null $carModel + * @param Brand|null $brand + * @return Embedding|null + */ public function createPersistedEmbedding(CarProperty $carProperty, ?CarRevision $carRevision, ?CarModel $carModel, ?Brand $brand): ?Embedding { $text = $carProperty->value->humanReadable(); diff --git a/src/Domain/ContentManagement/CarRevisionEmbedder.php b/src/Domain/ContentManagement/CarRevisionEmbedder.php new file mode 100644 index 0000000..307e676 --- /dev/null +++ b/src/Domain/ContentManagement/CarRevisionEmbedder.php @@ -0,0 +1,55 @@ +name; + if ($carModel !== null) { + $text .= ', ' . $carModel->name; + } + + if ($brand !== null) { + $text .= ', ' . $brand->name; + } + + $embedding = $this->embeddingRepository->findByPhrase($text); + if ($embedding) { + $carRevision->embeddings->add($embedding->embeddingId); + return $embedding; + } + + $largeEmbedding = $this->aiClient->embedTextLarge($text); + $smallEmbedding = $this->aiClient->embedTextSmall($text); + + $embedding = new Embedding(EmbeddingId::generate(), $text, $largeEmbedding, $smallEmbedding); + $this->embeddingRepository->save($embedding); + + $carRevision->embeddings->add($embedding->embeddingId); + + return $embedding; + } +} \ No newline at end of file diff --git a/src/Domain/Logging/LoggerTrait.php b/src/Domain/Logging/LoggerTrait.php new file mode 100644 index 0000000..2e0094e --- /dev/null +++ b/src/Domain/Logging/LoggerTrait.php @@ -0,0 +1,17 @@ +logger = $logger; + } +} \ No newline at end of file diff --git a/src/Domain/Model/Cars/Brand.php b/src/Domain/Model/Cars/Brand.php index 915f883..8ad3273 100644 --- a/src/Domain/Model/Cars/Brand.php +++ b/src/Domain/Model/Cars/Brand.php @@ -3,7 +3,7 @@ namespace App\Domain\Model\Cars; use App\Domain\Model\Id\BrandId; -use App\Domain\Model\EmbeddingCollection; +use App\Domain\Model\Embedding\EmbeddingCollection; final readonly class Brand { diff --git a/src/Domain/Model/Cars/CarRevision.php b/src/Domain/Model/Cars/CarRevision.php index bd94974..b261e38 100644 --- a/src/Domain/Model/Cars/CarRevision.php +++ b/src/Domain/Model/Cars/CarRevision.php @@ -4,6 +4,7 @@ namespace App\Domain\Model\Cars; use App\Domain\Model\Id\CarModelId; use App\Domain\Model\Id\CarRevisionId; +use App\Domain\Model\Id\EmbeddingIdCollection; use App\Domain\Model\Image; final readonly class CarRevision @@ -13,5 +14,6 @@ final readonly class CarRevision public readonly CarModelId $carModelId, public readonly string $name, public readonly ?Image $image = null, + public readonly EmbeddingIdCollection $embeddings = new EmbeddingIdCollection([]), ) {} } \ No newline at end of file diff --git a/src/Domain/Model/EmbeddingCollection.php b/src/Domain/Model/Embedding/EmbeddingCollection.php similarity index 81% rename from src/Domain/Model/EmbeddingCollection.php rename to src/Domain/Model/Embedding/EmbeddingCollection.php index c8b4bda..94ce328 100644 --- a/src/Domain/Model/EmbeddingCollection.php +++ b/src/Domain/Model/Embedding/EmbeddingCollection.php @@ -1,8 +1,7 @@ build($data); + $this->logger?->debug('Build view {view} with data {data}', ['view' => $view, 'data' => $data]); + + return $view->buildView($data); } } \ No newline at end of file diff --git a/src/Domain/Search/TileBuilder/ProductionPeriodTileBuilder.php b/src/Domain/Search/TileBuilder/ProductionPeriodTileBuilder.php deleted file mode 100644 index 614ba85..0000000 --- a/src/Domain/Search/TileBuilder/ProductionPeriodTileBuilder.php +++ /dev/null @@ -1,30 +0,0 @@ -value instanceof Production) { + return null; + } + + return new TileCollection([new ProductionPeriodTile( + $carProperty->value->productionBegin, + $carProperty->value->productionEnd, + )]); + } +} \ No newline at end of file diff --git a/src/Domain/Search/Tiles/RangeTile.php b/src/Domain/Search/Tiles/Range/RangeTile.php similarity index 77% rename from src/Domain/Search/Tiles/RangeTile.php rename to src/Domain/Search/Tiles/Range/RangeTile.php index bf449d9..6f56f13 100644 --- a/src/Domain/Search/Tiles/RangeTile.php +++ b/src/Domain/Search/Tiles/Range/RangeTile.php @@ -1,6 +1,6 @@ $tileBuilders @@ -15,7 +17,10 @@ final readonly class TileBuilderProvider public function __construct( #[AutowireIterator('app.tile_builder')] private iterable $tileBuilders, - ) {} + private readonly ?LoggerInterface $logger = null, + ) { + $this->logger?->debug('TileBuilderProvider initialized: ' . implode(', ', array_map(fn(TileBuilder $tileBuilder) => get_class($tileBuilder), iterator_to_array($this->tileBuilders)))); + } /** * @param CarProperty $carProperty @@ -29,6 +34,11 @@ final readonly class TileBuilderProvider } } - throw new \Exception(sprintf('No tile builder found for car property %s of type %s', $carProperty->carPropertyId->value, get_class($carProperty->value))); + $this->logger?->warning('No tile builder found for car property {carPropertyId} of type {carPropertyType}', [ + 'carPropertyId' => $carProperty->carPropertyId->value, + 'carPropertyType' => get_class($carProperty->value), + ]); + + return new TileCollection([]); } } \ No newline at end of file diff --git a/src/Domain/Search/Tiles/TopSpeedTile.php b/src/Domain/Search/Tiles/TopSpeed/TopSpeedTile.php similarity index 77% rename from src/Domain/Search/Tiles/TopSpeedTile.php rename to src/Domain/Search/Tiles/TopSpeed/TopSpeedTile.php index 8a0e791..a17788c 100644 --- a/src/Domain/Search/Tiles/TopSpeedTile.php +++ b/src/Domain/Search/Tiles/TopSpeed/TopSpeedTile.php @@ -1,6 +1,6 @@ $data * * @return TileCollection */ - public function build(array $data): TileCollection; + public function buildView(array $data): TileCollection; /** * @return array diff --git a/src/Domain/Search/View/ViewProvider.php b/src/Domain/Search/View/AiViewBuilderProvider.php similarity index 57% rename from src/Domain/Search/View/ViewProvider.php rename to src/Domain/Search/View/AiViewBuilderProvider.php index e6b3334..03728c9 100644 --- a/src/Domain/Search/View/ViewProvider.php +++ b/src/Domain/Search/View/AiViewBuilderProvider.php @@ -2,24 +2,28 @@ namespace App\Domain\Search\View; +use Psr\Log\LoggerInterface; use Symfony\Component\DependencyInjection\Attribute\AutowireIterator; -final readonly class ViewProvider +final readonly class AiViewBuilderProvider { /** - * @param iterable $views + * @param iterable $views */ public function __construct( - #[AutowireIterator('app.view')] + #[AutowireIterator('app.ai_view_builder')] private iterable $views, - ) {} + private readonly ?LoggerInterface $logger = null, + ) { + $this->logger?->debug('AiViewBuilderProvider initialized: ' . implode(', ', array_map(fn(AiViewBuilder $view) => get_class($view), iterator_to_array($this->views)))); + } /** * @param string $viewClass * - * @return View + * @return AiViewBuilder */ - public function getView(string $viewClass): View + public function getView(string $viewClass): AiViewBuilder { foreach ($this->views as $view) { $reflectionClass = new \ReflectionClass($view); @@ -34,7 +38,7 @@ final readonly class ViewProvider } /** - * @return array + * @return array */ public function getAllViews(): array { diff --git a/src/Domain/Search/View/CarRevisionComparison.php b/src/Domain/Search/View/CarRevisionComparison.php deleted file mode 100644 index 2fcc873..0000000 --- a/src/Domain/Search/View/CarRevisionComparison.php +++ /dev/null @@ -1,31 +0,0 @@ - $data - * - * @return TileCollection - */ - public function build(array $data): TileCollection - { - return new TileCollection([]); - } - - public function dataDescription(): array - { - return [ - 'car_revision_id_1' => 'Car revision ID 1', - 'car_revision_id_2' => 'Car revision ID 2', - ]; - } - - public function description(): string - { - return 'This view shows a comparison of two car revisions.'; - } -} \ No newline at end of file diff --git a/src/Domain/Search/View/CarRevisionComparison/CarRevisionComparisonAiViewBuilder.php b/src/Domain/Search/View/CarRevisionComparison/CarRevisionComparisonAiViewBuilder.php new file mode 100644 index 0000000..0b0f597 --- /dev/null +++ b/src/Domain/Search/View/CarRevisionComparison/CarRevisionComparisonAiViewBuilder.php @@ -0,0 +1,40 @@ +carRevisionComparisonView->buildView(new CarRevisionId($data['car_revision_id_1']), new CarRevisionId($data['car_revision_id_2'])); + } + + public function dataDescription(): array + { + return [ + 'car_revision_id_1' => 'Car revision ID 1', + 'car_revision_id_2' => 'Car revision ID 2', + ]; + } + + public function description(): string + { + return 'This view shows a comparison of two car revisions.'; + } +} \ No newline at end of file diff --git a/src/Domain/Search/View/CarRevisionComparison/CarRevisionComparisonView.php b/src/Domain/Search/View/CarRevisionComparison/CarRevisionComparisonView.php new file mode 100644 index 0000000..292fe46 --- /dev/null +++ b/src/Domain/Search/View/CarRevisionComparison/CarRevisionComparisonView.php @@ -0,0 +1,14 @@ + $data - * - * @return TileCollection - */ - public function build(array $data): TileCollection + public function __construct( + private readonly FullBrandView $fullBrandView + ) {} + + public function buildView(array $data): TileCollection { - return new TileCollection([]); + if (!is_string($data['brand_id'] ?? null)) { + throw new \InvalidArgumentException('Brand ID is required'); + } + + return $this->fullBrandView->buildView(new BrandId($data['brand_id'])); } public function dataDescription(): array diff --git a/src/Domain/Search/View/FullBrand/FullBrandView.php b/src/Domain/Search/View/FullBrand/FullBrandView.php new file mode 100644 index 0000000..d5a52f0 --- /dev/null +++ b/src/Domain/Search/View/FullBrand/FullBrandView.php @@ -0,0 +1,14 @@ + $data + * + * @return TileCollection + */ + public function buildView(array $data): TileCollection + { + if (!is_string($data['car_model_id'] ?? null)) { + throw new \InvalidArgumentException('Car model ID is required'); + } + + return $this->fullCarModelView->buildView(new CarModelId($data['car_model_id'])); + } + + public function dataDescription(): array + { + return [ + 'car_model_id' => 'Car model ID', + ]; + } + + public function description(): string + { + return <<<'EOT' + This view shows all information about a car model. It is used to display the full information about a car model if requested by the user. + E.g. a model name is given + EOT; + } +} \ No newline at end of file diff --git a/src/Domain/Search/View/FullCarModelView.php b/src/Domain/Search/View/FullCarModel/FullCarModelView.php similarity index 64% rename from src/Domain/Search/View/FullCarModelView.php rename to src/Domain/Search/View/FullCarModel/FullCarModelView.php index 09a2065..fd3b38b 100644 --- a/src/Domain/Search/View/FullCarModelView.php +++ b/src/Domain/Search/View/FullCarModel/FullCarModelView.php @@ -1,6 +1,6 @@ $data - * - * @return TileCollection - */ - public function build(array $data): TileCollection + public function buildView(CarModelId $carModelId): TileCollection { - if (!is_string($data['car_model_id'] ?? null)) { - throw new \InvalidArgumentException('Car model ID is required'); - } + $fullCarModel = $this->fullCarLoader->loadModel($carModelId); - $fullCarModel = $this->fullCarLoader->loadModel(new CarModelId($data['car_model_id'])); $carModel = $fullCarModel->getCarModel(); $brand = $fullCarModel->getBrand(); $carRevisions = $fullCarModel->getCarRevisions(); @@ -75,19 +65,4 @@ final readonly class FullCarModelView implements View return new TileCollection($allTiles); } - - public function dataDescription(): array - { - return [ - 'car_model_id' => 'Car model ID', - ]; - } - - public function description(): string - { - return <<<'EOT' - This view shows all information about a car model. It is used to display the full information about a car model if requested by the user. - E.g. a model name is given - EOT; - } } \ No newline at end of file diff --git a/src/Domain/Search/View/FullCarRevision/FullCarRevisionAiBuilder.php b/src/Domain/Search/View/FullCarRevision/FullCarRevisionAiBuilder.php new file mode 100644 index 0000000..ab5a918 --- /dev/null +++ b/src/Domain/Search/View/FullCarRevision/FullCarRevisionAiBuilder.php @@ -0,0 +1,56 @@ + $data + * + * @return TileCollection + */ + public function buildView(array $data): TileCollection + { + if (!is_string($data['car_revision_id'] ?? null)) { + throw new \InvalidArgumentException('Car revision ID is required'); + } + + return $this->fullCarRevisionView->buildView(new CarRevisionId($data['car_revision_id'])); + } + + public function dataDescription(): array + { + return [ + 'car_revision_id' => 'Car revision ID', + ]; + } + + public function description(): string + { + return <<<'EOT' + This view shows all information about a car revision. It is used to display the full information about a car revision if a specific revision is requested in the query. + E.g. a model and revision name is given. You should always prefer this view over the full car model view if enough information is given in the query. + EOT; + } +} \ No newline at end of file diff --git a/src/Domain/Search/View/FullCarRevisionView.php b/src/Domain/Search/View/FullCarRevision/FullCarRevisionView.php similarity index 57% rename from src/Domain/Search/View/FullCarRevisionView.php rename to src/Domain/Search/View/FullCarRevision/FullCarRevisionView.php index 0d305f6..9af51f6 100644 --- a/src/Domain/Search/View/FullCarRevisionView.php +++ b/src/Domain/Search/View/FullCarRevision/FullCarRevisionView.php @@ -1,6 +1,6 @@ $data - * - * @return TileCollection - */ - public function build(array $data): TileCollection + public function buildView(CarRevisionId $carRevisionId): TileCollection { - if (!is_string($data['car_revision_id'] ?? null)) { - throw new \InvalidArgumentException('Car revision ID is required'); - } - - $fullCar = $this->fullCarLoader->loadRevision(new CarRevisionId($data['car_revision_id'])); + $fullCar = $this->fullCarLoader->loadRevision($carRevisionId); $carRevision = $fullCar->getCarRevision(); $carModel = $fullCar->getCarModel(); @@ -47,6 +40,9 @@ final readonly class FullCarRevisionView implements View $carProperties->getOne(TopSpeed::class), $carProperties->getOne(Acceleration::class), $carProperties->getOne(RangeSpecification::class), + $carProperties->getOne(MotorPower::class), + $carProperties->getOne(AverageConsumption::class), + $carProperties->getOne(BatteryCapacity::class), ], static fn($value) => $value !== null); $tiles = new TileCollection([]); @@ -63,19 +59,4 @@ final readonly class FullCarRevisionView implements View ), ]); } - - public function dataDescription(): array - { - return [ - 'car_revision_id' => 'Car revision ID', - ]; - } - - public function description(): string - { - return <<<'EOT' - This view shows all information about a car revision. It is used to display the full information about a car revision if a specific revision is requested in the query. - E.g. a model and revision name is given. You should always prefer this view over the full car model view if enough information is given in the query. - EOT; - } } \ No newline at end of file diff --git a/src/Domain/Search/View/SpecificCarProperty/SpecificCarPropertyAiViewBuilder.php b/src/Domain/Search/View/SpecificCarProperty/SpecificCarPropertyAiViewBuilder.php new file mode 100644 index 0000000..d709aeb --- /dev/null +++ b/src/Domain/Search/View/SpecificCarProperty/SpecificCarPropertyAiViewBuilder.php @@ -0,0 +1,47 @@ + $data + * + * @return TileCollection + */ + public function buildView(array $data): TileCollection + { + if (!is_array($data['properties'] ?? null)) { + throw new \InvalidArgumentException('Properties must be an array'); + } + + /** @var array $propertyIds */ + $propertyIds = array_map(fn($propertyId) => is_string($propertyId) ? new CarPropertyId($propertyId) : null, $data['properties']); + $propertyIds = array_filter($propertyIds, static fn($propertyId) => $propertyId !== null); + + return $this->specificCarPropertyView->buildView($propertyIds); + } + + public function dataDescription(): array + { + return [ + 'properties' => [ + 'carproperty_123', + 'carproperty_456', + ], + ]; + } + + public function description(): string + { + return 'This view shows all information about a specific car property.'; + } +} \ No newline at end of file diff --git a/src/Domain/Search/View/SpecificCarProperty/SpecificCarPropertyView.php b/src/Domain/Search/View/SpecificCarProperty/SpecificCarPropertyView.php new file mode 100644 index 0000000..cc38dc0 --- /dev/null +++ b/src/Domain/Search/View/SpecificCarProperty/SpecificCarPropertyView.php @@ -0,0 +1,44 @@ + $carPropertyIds + * + * @return TileCollection + */ + public function buildView(array $carPropertyIds): TileCollection + { + $tiles = []; + foreach ($carPropertyIds as $carPropertyId) { + $carProperty = $this->carPropertyRepository->findById($carPropertyId); + if ($carProperty === null) { + continue; + } + + $fullCar = $this->fullCarLoader->loadRevisionByCarPropertyId($carProperty->carPropertyId); + + $tileCollection = $this->tileBuilderProvider->build($carProperty); + + $tiles[] = new SectionTile($fullCar->getBrand()->name . ' ' . $fullCar->getCarModel()->name . ' ' . $fullCar->getCarRevision()->name); + $tiles = array_merge($tiles, $tileCollection->array()); + } + + return new TileCollection($tiles); + } +} \ No newline at end of file diff --git a/src/Domain/Search/View/SpecificCarPropertyView.php b/src/Domain/Search/View/SpecificCarPropertyView.php deleted file mode 100644 index a78e6c8..0000000 --- a/src/Domain/Search/View/SpecificCarPropertyView.php +++ /dev/null @@ -1,72 +0,0 @@ - $data - * - * @return TileCollection - */ - public function build(array $data): TileCollection - { - $properties = $data['properties'] ?? []; - if (!is_array($properties)) { - throw new \Exception('Properties must be an array'); - } - - $tiles = []; - foreach ($properties as $propertyId) { - if (!is_string($propertyId)) { - continue; - } - - $carProperty = $this->carPropertyRepository->findById(new CarPropertyId($propertyId)); - if ($carProperty === null) { - continue; - } - - $fullCar = $this->fullCarLoader->loadRevisionByCarPropertyId($carProperty->carPropertyId); - - $tileCollection = $this->tileBuilderProvider->build($carProperty); - - $tiles[] = new SectionTile($fullCar->getBrand()->name . ' ' . $fullCar->getCarModel()->name . ' ' . $fullCar->getCarRevision()->name); - $tiles = array_merge($tiles, $tileCollection->array()); - } - - return new TileCollection($tiles); - } - - public function dataDescription(): array - { - return [ - 'properties' => [ - 'carproperty_123', - 'carproperty_456', - ], - ]; - } - - public function description(): string - { - return 'This view shows all information about a specific car property.'; - } -} \ No newline at end of file diff --git a/src/Infrastructure/PostgreSQL/Repository/EmbeddingRepository/SqlEmbeddingRepository.php b/src/Infrastructure/PostgreSQL/Repository/EmbeddingRepository/SqlEmbeddingRepository.php index 3a7b348..5042c79 100644 --- a/src/Infrastructure/PostgreSQL/Repository/EmbeddingRepository/SqlEmbeddingRepository.php +++ b/src/Infrastructure/PostgreSQL/Repository/EmbeddingRepository/SqlEmbeddingRepository.php @@ -2,18 +2,21 @@ namespace App\Infrastructure\PostgreSQL\Repository\EmbeddingRepository; +use App\Domain\Logging\LoggerTrait; use Doctrine\DBAL\Connection; use App\Domain\Repository\EmbeddingRepository; use App\Domain\Model\Embedding\Embedding; use App\Domain\Model\Embedding\LargeEmbeddingVector; use App\Domain\Model\Embedding\SmallEmbeddingVector; -use App\Domain\Model\EmbeddingCollection; +use App\Domain\Model\Embedding\EmbeddingCollection; use App\Domain\Model\Value\Vector; use App\Domain\Model\Id\EmbeddingId; use App\Domain\Model\Id\EmbeddingIdCollection; final class SqlEmbeddingRepository implements EmbeddingRepository { + use LoggerTrait; + public function __construct( private readonly Connection $connection, ) {} @@ -66,29 +69,33 @@ final class SqlEmbeddingRepository implements EmbeddingRepository return $this->mapRowToEmbedding($row); } - public function searchByLargeEmbeddingVector(LargeEmbeddingVector $embeddingVector, int $limit = 20): EmbeddingCollection + public function searchByLargeEmbeddingVector(LargeEmbeddingVector $embeddingVector, int $limit = 99): EmbeddingCollection { $result = $this->connection->executeQuery( 'SELECT *, large_embedding_vector <=> :embeddingVector AS distance FROM embeddings WHERE large_embedding_vector IS NOT NULL - ORDER BY large_embedding_vector <=> :embeddingVector - LIMIT :limit', + ORDER BY large_embedding_vector <=> :embeddingVector', [ 'embeddingVector' => '[' . implode(',', $embeddingVector->vector->values) . ']', - 'limit' => $limit, ] ); $embeddings = []; foreach ($result->fetchAllAssociative() as $row) { + if ($row['distance'] > 0.5) { + continue; + } + + $this->logger?->debug('Found embedding {embedding} with distance {distance}', ['embedding' => $row['phrase'], 'distance' => $row['distance']]); + $embeddings[] = $this->mapRowToEmbedding($row); } return new EmbeddingCollection($embeddings); } - public function searchBySmallEmbeddingVector(SmallEmbeddingVector $smallEmbeddingVector, int $limit = 20): EmbeddingCollection + public function searchBySmallEmbeddingVector(SmallEmbeddingVector $smallEmbeddingVector, int $limit = 99): EmbeddingCollection { $result = $this->connection->executeQuery( 'SELECT * diff --git a/symfony.lock b/symfony.lock index c902303..1112e5c 100644 --- a/symfony.lock +++ b/symfony.lock @@ -136,6 +136,18 @@ "ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f" } }, + "symfony/monolog-bundle": { + "version": "3.10", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "3.7", + "ref": "aff23899c4440dd995907613c1dd709b6f59503f" + }, + "files": [ + "config/packages/monolog.yaml" + ] + }, "symfony/routing": { "version": "7.2", "recipe": { diff --git a/templates/_components/result.html.twig b/templates/_components/result.html.twig new file mode 100644 index 0000000..a30e9c2 --- /dev/null +++ b/templates/_components/result.html.twig @@ -0,0 +1,3 @@ +
+ {% include 'result/tiles/collection.html.twig' with { tiles: tiles } %} +
\ No newline at end of file diff --git a/templates/_components/search.html.twig b/templates/_components/search.html.twig index 1088a0f..46d368f 100644 --- a/templates/_components/search.html.twig +++ b/templates/_components/search.html.twig @@ -1,7 +1,8 @@
@@ -9,39 +10,380 @@ document.addEventListener('DOMContentLoaded', function() { const searchForm = document.getElementById('searchForm'); const searchInput = document.getElementById('searchInput'); + const searchButton = document.getElementById('searchButton'); + const resultsContainer = document.getElementById('resultsContainer'); function encodeSearchQuery(query) { - query = query.replace(/[^a-zA-Z0-9+\-\s]/g, ''); return encodeURIComponent(query); } + function setLoading(isLoading) { + if (isLoading) { + searchForm.classList.add('loading'); + searchButton.querySelector('.search-btn-content').style.display = 'none'; + searchButton.querySelector('.search-btn-spinner').style.display = 'inline-block'; + + if (resultsContainer) { + if (resultsContainer.innerHTML.trim()) { + // If there is already content, just fade it + resultsContainer.classList.add('loading-fade'); + } else { + // If no content, show placeholders + fadeOutIn(resultsContainer, ` +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `, () => { + resultsContainer.style.display = 'block'; + }); + } + } + } else { + searchForm.classList.remove('loading'); + // Hide spinner, show button content + searchButton.querySelector('.search-btn-content').style.display = 'inline-block'; + searchButton.querySelector('.search-btn-spinner').style.display = 'none'; + if (resultsContainer) { + resultsContainer.classList.remove('loading-fade'); + } + } + } + + function fadeOutIn(element, newContent, callback) { + element.classList.add('fade-out'); + setTimeout(() => { + element.innerHTML = newContent; + element.classList.remove('fade-out'); + element.classList.add('fade-in'); + setTimeout(() => { + element.classList.remove('fade-in'); + if (callback) callback(); + }, 150); // match the CSS transition duration + }, 150); // match the CSS transition duration + } + + function performSearch(query) { + if (!query.trim()) { + return; + } + + const encodedQuery = encodeSearchQuery(query); + + // Show loading animation on search bar + setLoading(true); + + // Update URL without page reload + const newUrl = `/s/${encodedQuery}`; + window.history.pushState({ query: query }, '', newUrl); + + // Make AJAX request to get results + fetch(`/result/${encodedQuery}`) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.text(); + }) + .then(html => { + // Hide loading animation + setLoading(false); + + fadeOutIn(resultsContainer, html, () => { + resultsContainer.style.display = 'block'; + }); + }) + .catch(error => { + console.error('Search error:', error); + + // Hide loading animation + setLoading(false); + + // Show error message + fadeOutIn(resultsContainer, '
Sorry, there was an error performing your search. Please try again.
', () => { + resultsContainer.style.display = 'block'; + }); + }); + } + + // Handle form submission searchForm.addEventListener('submit', function(e) { e.preventDefault(); const query = searchInput.value.trim(); - if (query) { - const encodedQuery = encodeSearchQuery(query); - window.location.href = `/s/${encodedQuery}`; - } + performSearch(query); }); + // Handle search button click searchButton.addEventListener('click', function(e) { e.preventDefault(); const query = searchInput.value.trim(); - if (query) { - const encodedQuery = encodeSearchQuery(query); - window.location.href = `/s/${encodedQuery}`; - } + performSearch(query); }); + // Handle Enter key press 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}`; - } + performSearch(query); } }); + + // Handle browser back/forward buttons + window.addEventListener('popstate', function(event) { + if (event.state && event.state.query) { + searchInput.value = event.state.query; + performSearch(event.state.query); + } + }); + + // Check for initial results on the page + const initialResults = document.getElementById('initialResults'); + const initialQuery = searchInput.value.trim(); + + if (initialResults && initialResults.innerHTML.trim()) { + // Show initial results if they exist + resultsContainer.innerHTML = initialResults.innerHTML; + resultsContainer.style.display = 'block'; + } else if (initialQuery) { + // If there's a query but no initial results, perform search + performSearch(initialQuery); + } }); - \ No newline at end of file + + + \ No newline at end of file diff --git a/templates/base.html.twig b/templates/base.html.twig index 124724d..5c0f235 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -263,7 +263,6 @@ .loading { text-align: center; - padding: 2rem; color: #666; } diff --git a/templates/home/index.html.twig b/templates/home/index.html.twig index 4138587..76ae38c 100644 --- a/templates/home/index.html.twig +++ b/templates/home/index.html.twig @@ -8,4 +8,5 @@ {% include '_components/search.html.twig' %} +
{% endblock %} \ No newline at end of file diff --git a/templates/result/index.html.twig b/templates/result/index.html.twig index 5f300ae..3098f42 100644 --- a/templates/result/index.html.twig +++ b/templates/result/index.html.twig @@ -6,8 +6,10 @@ {% include '_components/search.html.twig' with { query: query } %} - -
- {% include 'result/tiles/collection.html.twig' with { tiles: tiles } %} -
+ + {% if tiles is defined and tiles|length > 0 %} +
+ {% include '_components/result.html.twig' with { tiles: tiles } %} +
+ {% endif %} {% endblock %} \ No newline at end of file