From dcc8db3fffe76477d503f86ba11ac5d280411951 Mon Sep 17 00:00:00 2001
From: Dominik Hebeler <dominik@suma-ev.de>
Date: Thu, 9 Jan 2025 12:04:42 +0100
Subject: [PATCH 1/7] enable suggestions option in settings

---
 .../Http/Controllers/SettingsController.php   |  4 ++--
 metager/app/SearchSettings.php                |  8 +++++---
 .../resources/views/settings/index.blade.php  | 20 +++++++++----------
 3 files changed, 16 insertions(+), 16 deletions(-)

diff --git a/metager/app/Http/Controllers/SettingsController.php b/metager/app/Http/Controllers/SettingsController.php
index 49a1ed66f..092be3218 100644
--- a/metager/app/Http/Controllers/SettingsController.php
+++ b/metager/app/Http/Controllers/SettingsController.php
@@ -296,8 +296,8 @@ class SettingsController extends Controller
         if (!empty($suggestions)) {
             if ($suggestions === "off") {
                 Cookie::queue(Cookie::forever('suggestions', 'off', '/', null, $secure, false));
-            } elseif ($suggestions === "on") {
-                Cookie::queue(Cookie::forget("suggestions", "/"));
+            } elseif ($suggestions === "serper") {
+                Cookie::queue(Cookie::forever('suggestions', 'serper', '/', null, $secure, false));
             }
         }
 
diff --git a/metager/app/SearchSettings.php b/metager/app/SearchSettings.php
index bcd984f16..11246524e 100644
--- a/metager/app/SearchSettings.php
+++ b/metager/app/SearchSettings.php
@@ -88,9 +88,11 @@ class SearchSettings
         $this->tiles_startpage = $this->getSettingValue("tiles_startpage", true);
         $this->tiles_startpage = $this->tiles_startpage !== "off" ? true : false;
 
-        $suggestions = $this->getSettingValue("suggestions", "bing");
-        if ($suggestions === "off") {
-            $this->suggestions = "off";
+        $suggestions = $this->getSettingValue("suggestions", null);
+        if (in_array($suggestions, ["off", "serper"])) {
+            $this->suggestions = $suggestions;
+        } else {
+            $this->suggestions = null;
         }
 
         if ($this->getSettingValue("quicktips") !== null) {
diff --git a/metager/resources/views/settings/index.blade.php b/metager/resources/views/settings/index.blade.php
index b58f4f313..10fd4812d 100644
--- a/metager/resources/views/settings/index.blade.php
+++ b/metager/resources/views/settings/index.blade.php
@@ -204,17 +204,15 @@
             <form id="setting-form" action="{{ route('enableSetting') }}" method="post" class="form">
                 <input type="hidden" name="focus" value="{{ $fokus }}">
                 <input type="hidden" name="url" value="{{ $url }}">
-                @if (config('metager.metager.admitad.suggestions_enabled'))
-                    <div class="form-group">
-                        <label for="sg">@lang('settings.suggestions.label')</label>
-                        <select name="sg" id="sg" class="form-control">
-                            <option value="off" {{ app(App\SearchSettings::class)->suggestions === 'off' ? 'disabled selected' : '' }}>
-                                @lang('settings.suggestions.off')</option>
-                            <option value="on" {{ app(App\SearchSettings::class)->suggestions !== 'off' ? 'disabled selected' : '' }}>
-                                @lang('settings.suggestions.on')</option>
-                        </select>
-                    </div>
-                @endif
+                <div class="form-group">
+                    <label for="sg">@lang('settings.suggestions.label')</label>
+                    <select name="sg" id="sg" class="form-control">
+                        <option value="off" {{ in_array(app(App\SearchSettings::class)->suggestions, [null, "off"]) ? 'disabled selected' : '' }}>
+                            @lang('settings.suggestions.off')</option>
+                        <option value="serper" {{ app(App\SearchSettings::class)->suggestions === 'serper' ? 'disabled selected' : '' }}>
+                           Serper</option>
+                    </select>
+                </div>
                 <div class="form-group">
                     <label for="self_advertisements">@lang('settings.self_advertisements.label')</label>
                     <select name="self_advertisements" id="self_advertisements" class="form-control">
-- 
GitLab


From c0825b46a86020970ca779f2b14172b234717d8d Mon Sep 17 00:00:00 2001
From: Dominik Hebeler <dominik@suma-ev.de>
Date: Tue, 21 Jan 2025 10:08:54 +0100
Subject: [PATCH 2/7] implement suggestions on the startpage

---
 .../Http/Controllers/SuggestionController.php |  79 +----------
 metager/app/Models/Suggestions/Serper.php     |  44 ++++++
 metager/app/Suggestions.php                   |  84 +++++++++++
 metager/config/.gitignore                     |   1 +
 metager/config/metager/suggestions.php        |  10 ++
 metager/resources/js/startpage/app.js         |   3 +
 metager/resources/js/suggest.js               |  97 +++++--------
 .../less/metager/parts/searchbar.less         | 133 +++++++-----------
 .../resources/views/parts/searchbar.blade.php |  90 ++++--------
 metager/routes/web.php                        |   5 +-
 10 files changed, 267 insertions(+), 279 deletions(-)
 create mode 100644 metager/app/Models/Suggestions/Serper.php
 create mode 100644 metager/app/Suggestions.php
 create mode 100644 metager/config/metager/suggestions.php

diff --git a/metager/app/Http/Controllers/SuggestionController.php b/metager/app/Http/Controllers/SuggestionController.php
index ef031df21..10615cdcf 100644
--- a/metager/app/Http/Controllers/SuggestionController.php
+++ b/metager/app/Http/Controllers/SuggestionController.php
@@ -6,6 +6,7 @@ use App\Localization;
 use App\Models\Authorization\Authorization;
 use App\Models\Result;
 use App\SearchSettings;
+use App\Suggestions;
 use Cache;
 use Crypt;
 use Exception;
@@ -18,88 +19,22 @@ class SuggestionController extends Controller
         "us" => "us(en)",
         "ch" => "ch(de)",
     ];
-    public function partner(Request $request)
-    {
-        if (!$this->verifySignature($request)) {
-            abort(401);
-        }
-        $query = $request->input("query");
-        if (!config("metager.metager.admitad.suggestions_enabled") || empty($query)) {
-            abort(404);
-        }
-
-        // Disable Partnershops for authorized searches
-        if (app(Authorization::class)->canDoAuthenticatedSearch()) {
-            return response()->json([], 200, ["Cache-Control" => "no-cache, private"]);
-        }
-
-        $region = strtolower(Localization::getRegion());
-        if (array_key_exists($region, $this->markets)) {
-            $region = $this->markets[$region];
-        }
-        $public_key = config("metager.metager.admitad.suggest_public_key");
-
-        $request_data = [
-            "keywords" => [
-                $query
-            ],
-            "market" => $region,
-            "provider" => "bing-search"
-        ];
-
-        $cache_key = md5(json_encode($request_data));
-        $response = Cache::get($cache_key);
-        if ($response === null) {
-            $context = stream_context_create([
-                "http" => [
-                    "method" => "POST",
-                    "header" => [
-                        "Content-Type: application/json",
-                        "Authorization: Bearer $public_key"
-                    ],
-                    "user_agent" => "MetaGer",
-                    "timeout" => 2.0,
-                    "content" => json_encode($request_data),
-                    "ignore_errors" => true
-                ]
-            ]);
-            $response = file_get_contents("https://apisuggests.com/api/v1/resolve", false, $context);
-            $response = json_decode($response, true);
-            if (array_key_exists("resolutions", $response) && is_array($response["resolutions"])) {
-                Cache::put($cache_key, $response, now()->addHours(self::CACHE_DURATION_HOURS));
-            }
-        }
-        if (array_key_exists("resolutions", $response) && is_array($response["resolutions"])) {
-            $result = [];
-            for ($i = 0; $i < sizeof($response["resolutions"]); $i++) {
-                if ($response["resolutions"][$i]["data"] === null) {
-                    continue;
-                }
-                $response["resolutions"][$i]["data"]["imageUrl"] = Pictureproxy::generateUrl($response["resolutions"][$i]["data"]["imageUrl"]);
-                $result[] = $response["resolutions"][$i];
-            }
-            return response()->json($result, 200, ["Cache-Control" => "max-age=7200"]);
-        } else {
-            return response()->json([], 200, ["Cache-Control" => "no-cache, private"]);
-        }
-    }
 
     public function suggest(Request $request)
     {
-        if (!$this->verifySignature($request)) {
-            abort(401);
-        }
         $query = $request->input("query");
-        if (!config("metager.metager.admitad.suggestions_enabled") || empty($query)) {
-            abort(404);
-        }
 
         // Do not generate Suggestions if User turned them off
         $settings = app(SearchSettings::class);
-        if ($settings->suggestions === "off") {
+        if (in_array($settings->suggestions, [null, "off"])) {
             return response()->json([], 200, ["Cache-Control" => "no-cache, private"]);
         }
         $suggestion_provider = $settings->suggestions;
+
+        $suggestions = Suggestions::fromProviderName($suggestion_provider, $query);
+        $suggestions = $suggestions->fetch();
+        return response()->json($suggestions, 200, ["Cache-Control" => "max-age=7200"]);
+
         $region = strtolower(Localization::getRegion());
         if (array_key_exists($region, $this->markets)) {
             $region = $this->markets[$region];
diff --git a/metager/app/Models/Suggestions/Serper.php b/metager/app/Models/Suggestions/Serper.php
new file mode 100644
index 000000000..074a9ed8b
--- /dev/null
+++ b/metager/app/Models/Suggestions/Serper.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace App\Models\Suggestions;
+
+use App\Localization;
+use App\Suggestions;
+
+class Serper extends Suggestions
+{
+    public const NAME = "serper";
+
+    public function __construct(string $query)
+    {
+        $this->query = $query;
+        $this->api_method_post = true;
+        $this->api_base = "https://google.serper.dev/autocomplete";
+        $this->api_post_data = json_encode([
+            "q" => $query,
+            "gl" => Localization::getRegion(),
+            "hl" => Localization::getLanguage()
+        ]);
+        $this->api_header[] = "X-API-KEY: " . config("metager.suggestions.serper.api_key");
+        $this->api_header[] = "Content-Type: application/json";
+    }
+
+    public function fetch()
+    {
+        return parent::fetch();
+    }
+
+    protected function parseResponse(string $response): array
+    {
+        try {
+            $suggestion_response = json_decode($response, true);
+            $result = [];
+            foreach ($suggestion_response["suggestions"] as $suggestion) {
+                $result[] = $suggestion["value"];
+            }
+            return $result;
+        } catch (Exception $e) {
+            return [];
+        }
+    }
+}
\ No newline at end of file
diff --git a/metager/app/Suggestions.php b/metager/app/Suggestions.php
new file mode 100644
index 000000000..1bbc00b8c
--- /dev/null
+++ b/metager/app/Suggestions.php
@@ -0,0 +1,84 @@
+<?php
+
+namespace App;
+
+use App\Models\Suggestions\Serper;
+use Exception;
+
+/**
+ * Base class for all search suggestions implementations
+ * by different providers
+ */
+abstract class Suggestions
+{
+    public const NAME = "";
+    protected string $query;
+    /** Should the request be made as POST request. GET method is used otherwise */
+    protected bool $api_method_post = false;
+    protected int $api_success_response_code = 200;
+    protected string $api_base;
+    /**
+     * Only used if $api_method_post == true
+     * Defines the Post data to send
+     * @var string
+     */
+    protected string $api_post_data;
+    /**
+     * Only used if $api_method_post == false
+     * Defines the GET-Parameters to attach to the URL
+     * @var array
+     */
+    protected array $api_get_parameters = [];
+    protected array $api_header = [];
+
+
+    abstract public function __construct(string $query);
+    public static function fromProviderName(string $provider, string $query): Suggestions|null
+    {
+        switch ($provider) {
+            case "serper":
+                return new Serper($query);
+            default:
+                return null;
+        }
+    }
+
+    /**
+     * Parses the server response and returns an array of suggestions
+     * @return array
+     */
+    abstract protected function parseResponse(string $response): array;
+
+    public function fetch()
+    {
+        $api_url = $this->api_base;
+        if ($this->api_method_post === false && sizeof($this->api_get_parameters) > 0) {
+            $api_url .= "?" . http_build_query($this->api_get_parameters);
+        }
+        $ch = curl_init($api_url);
+        curl_setopt_array($ch, [
+            CURLOPT_USERAGENT => "MetaGer",
+            CURLOPT_HTTPHEADER => $this->api_header,
+            CURLOPT_RETURNTRANSFER => true,
+            CURLOPT_FOLLOWLOCATION => true,
+            CURLOPT_MAXREDIRS => 10,
+            CURLOPT_TIMEOUT => 3,
+            CURLOPT_POST => $this->api_method_post,
+        ]);
+        if ($this->api_method_post) {
+            curl_setopt($ch, CURLOPT_POSTFIELDS, $this->api_post_data);
+        }
+
+        $response = curl_exec($ch);
+
+        if (curl_getinfo($ch, CURLINFO_HTTP_CODE) === $this->api_success_response_code) {
+            try {
+                return $this->parseResponse($response);
+            } catch (Exception $e) {
+                return [];
+            }
+        } else {
+            return [];
+        }
+    }
+}
\ No newline at end of file
diff --git a/metager/config/.gitignore b/metager/config/.gitignore
index e35db70e1..1b466a909 100644
--- a/metager/config/.gitignore
+++ b/metager/config/.gitignore
@@ -1,4 +1,5 @@
 sumas.json
+suggestions.json
 blacklistUrl.txt
 blacklistDomains.txt
 spam.txt
\ No newline at end of file
diff --git a/metager/config/metager/suggestions.php b/metager/config/metager/suggestions.php
new file mode 100644
index 000000000..6d4521999
--- /dev/null
+++ b/metager/config/metager/suggestions.php
@@ -0,0 +1,10 @@
+<?php
+
+$suggestions = [];
+$suggestions = json_decode(file_get_contents(config_path("suggestions.json")), true);
+
+return [
+    "serper" => [
+        "api_key" => array_key_exists("serper", $suggestions) && array_key_exists("api_key", $suggestions["serper"]) ? $suggestions["serper"]["api_key"] : null
+    ]
+];
\ No newline at end of file
diff --git a/metager/resources/js/startpage/app.js b/metager/resources/js/startpage/app.js
index a8845d071..262b5dccd 100644
--- a/metager/resources/js/startpage/app.js
+++ b/metager/resources/js/startpage/app.js
@@ -1,4 +1,5 @@
 import "./tiles";
+import { initializeSuggestions } from "../suggest";
 
 // Register Keyboard listener for quicklinks on startpage
 (async () => {
@@ -30,4 +31,6 @@ import "./tiles";
     },
     { once: true }
   );
+
+  initializeSuggestions();
 })();
\ No newline at end of file
diff --git a/metager/resources/js/suggest.js b/metager/resources/js/suggest.js
index 513171f5d..1d112ea72 100644
--- a/metager/resources/js/suggest.js
+++ b/metager/resources/js/suggest.js
@@ -1,12 +1,10 @@
 /**
  * MetaGers basic suggestion module
  */
-let suggestions = [];
-let partners = [];
-let query = "";
-
-let suggest_timeout = null;
-(() => {
+export function initializeSuggestions() {
+  let suggestions = [];
+  let query = "";
+  let suggest_timeout = null;
   let searchbar_container = document.querySelector(".searchbar");
   if (!searchbar_container) {
     return;
@@ -14,15 +12,9 @@ let suggest_timeout = null;
   let suggestions_container = searchbar_container.querySelector(".suggestions");
   if (!suggestions_container) {
     return;
-  } else {
-    suggestions_container.style.display = "grid";
   }
-  let suggestion_url_partner = suggestions_container.dataset.partners;
   let suggestion_url = suggestions_container.dataset.suggestions;
-  let key = suggestions_container.dataset.suggest;
-  if (!key || typeof key != "string" || key.length == 0) {
-    return;
-  }
+
   let search_input = searchbar_container.querySelector("input[name=eingabe]");
   if (!search_input) {
     return;
@@ -34,10 +26,13 @@ let suggest_timeout = null;
       e.target.blur();
     } else {
       clearSuggestTimeout();
-      suggest_timeout = setTimeout(suggest, 800);
+      suggest_timeout = setTimeout(suggest, 600);
     }
   });
-  search_input.addEventListener("focusin", suggest);
+  search_input.addEventListener("focusin", e => {
+    e.preventDefault();
+    suggest();
+  });
   search_input.addEventListener("change", (e) => {
     if (search_input.value.trim() == "") {
       query = "";
@@ -55,37 +50,18 @@ let suggest_timeout = null;
   function suggest() {
     if (search_input.value.trim().length <= 3 || navigator.webdriver) {
       suggestions = [];
-      partners = [];
       updateSuggestions();
       return;
     }
     if (search_input.value.trim() == query) {
+      updateSuggestions();
       return;
     } else {
       query = search_input.value.trim();
     }
 
-    fetch(suggestion_url_partner + "?query=" + encodeURIComponent(query), {
-      method: "GET",
-      headers: {
-        "Content-Type": "application/json",
-        "MetaGer-Key": key,
-      },
-    })
-      .then((response) => response.json())
-      .then((response) => {
-        partners = response;
-        updateSuggestions();
-      }).catch(reason => {
-        partners = [];
-        updateSuggestions();
-      });
-
     fetch(suggestion_url + "?query=" + encodeURIComponent(query), {
       method: "GET",
-      headers: {
-        "MetaGer-Key": key,
-      },
     })
       .then((response) => response.json())
       .then((response) => {
@@ -99,38 +75,16 @@ let suggest_timeout = null;
 
   function updateSuggestions() {
     // Enable/Disable Suggestions
-    if (suggestions.length > 0 || partners.length > 0) {
+    if (suggestions.length > 0) {
+      suggestions_container.style.display = "grid";
       searchbar_container.dataset.suggest = "active";
     } else {
+      suggestions_container.style.display = "none";
       searchbar_container.dataset.suggest = "inactive";
     }
 
-    // Add all Partners
-    suggestions_container
-      .querySelectorAll(".partner")
-      .forEach((value, index) => {
-        if (partners.length < index + 1) {
-          value.style.display = "none";
-          return;
-        } else {
-          value.style.display = "flex";
-        }
-        value.href = partners[index].data.deeplink;
-        let title_container = value.querySelector(".title");
-        if (title_container) {
-          title_container.textContent = partners[index].data.hostname;
-        }
-        let description_container = value.querySelector(".description");
-        if (description_container) {
-          description_container.textContent = partners[index].data.title;
-        }
-        let image_container = value.querySelector("img");
-        if (image_container) {
-          image_container.src = partners[index].data.imageUrl;
-        }
-      });
-
     // Add all Suggestions
+    let eingabe_container = document.querySelector("input[name=eingabe]");
     suggestions_container
       .querySelectorAll(".suggestion")
       .forEach((value, index) => {
@@ -140,11 +94,24 @@ let suggest_timeout = null;
         } else {
           value.style.display = "flex";
         }
-        value.value = suggestions[index];
+
+        let search_button = value.querySelector("button");
+        if (!search_button) return 1;
         let title_container = value.querySelector("span");
-        if (title_container) {
-          title_container.textContent = suggestions[index];
+        if (!title_container) return 1;
+
+        search_button.value = suggestions[index];
+        title_container.textContent = suggestions[index];
+
+        if (eingabe_container) {
+          title_container.onclick = e => {
+            eingabe_container.value = suggestions[index] + " ";
+            eingabe_container.focus();
+          };
         }
       });
+    if (suggestions.length > 0) {
+      setTimeout(() => searchbar_container.scrollIntoView(false), 250);
+    }
   }
-})();
+}
diff --git a/metager/resources/less/metager/parts/searchbar.less b/metager/resources/less/metager/parts/searchbar.less
index bacba0ba6..8f93353dc 100644
--- a/metager/resources/less/metager/parts/searchbar.less
+++ b/metager/resources/less/metager/parts/searchbar.less
@@ -16,10 +16,11 @@
   &[data-suggest="active"]:focus-within {
     grid-template-rows: max-content 1fr;
 
-    > .suggestions {
+    >.suggestions {
       border: 1px solid @searchbar-border-color;
       border-top: 0;
     }
+
   }
 
   .search-input-submit {
@@ -40,12 +41,12 @@
         padding-right: 1rem;
       }
 
-      > a {
+      >a {
         color: #585858;
         display: block;
         height: 100%;
 
-        > img {
+        >img {
           height: 100%;
           display: block;
           transform: rotate(-90deg);
@@ -104,11 +105,10 @@
         }
       }
 
-      &:focus-within:not(.search-delete-js-only)
-        > input:not(:placeholder-shown) {
+      &:focus-within:not(.search-delete-js-only)>input:not(:placeholder-shown) {
         padding-right: 35px; // Makes it so the overlayed delete button does not hide the text below
 
-        + #search-delete-btn {
+        +#search-delete-btn {
           display: grid;
         }
       }
@@ -126,7 +126,7 @@
         color: #585858;
         filter: invert(@icon-color);
 
-        > img#searchbar-img-lupe {
+        >img#searchbar-img-lupe {
           height: 100%;
           display: block;
         }
@@ -134,7 +134,7 @@
     }
   }
 
-  > .suggestions {
+  >.suggestions {
     --highlight-color: @highlight-color;
     display: grid;
     display: none;
@@ -142,83 +142,54 @@
     border-left: initial;
     border-top: 0;
     border: 0;
+    margin-bottom: 1rem;
     color: @text-color;
     background-color: @body-background-color;
 
-    > .partners {
-      display: flex;
-      gap: 1rem;
-      background-color: var(--highlight-color);
-      padding-inline: 0.5rem;
+    >.suggestion {
+      display: none;
+      align-items: center;
+      color: inherit;
 
-      > .partner {
-        display: none;
-        gap: 0.5rem;
-        align-items: center;
-        justify-content: center;
-        padding-block: 1rem;
-        color: inherit;
-
-        > img {
-          height: 1em;
+      >button {
+        cursor: pointer;
+        height: 100%;
+        padding-inline: 0.5rem;
+
+        >img {
+          height: 1rem;
+          filter: invert(@icon-color);
         }
 
-        > div {
-          display: grid;
-          align-items: center;
-          grid-template-columns: max-content 1fr;
-          column-gap: 0.5rem;
-
-          > .description {
-            grid-column: span 2;
-            font-size: 0.7rem;
-          }
-
-          > div {
-            > .mark {
-              color: limegreen;
-              font-size: 0.5rem;
-              width: max-content;
-              border: 1px solid limegreen;
-              padding: 0.1rem 0.25rem;
-              border-radius: 5px;
-            }
-          }
+        &:hover {
+          background-color: var(--highlight-color);
         }
       }
-    }
-
-    > .suggestion {
-      display: none;
-      cursor: pointer;
-      align-items: center;
-      gap: 1rem;
-      padding: 0.25rem 0.5rem;
-      color: inherit;
 
-      > img {
-        height: 1rem;
-        filter: invert(@icon-color);
-      }
+      >span {
+        flex-grow: 1;
+        padding: 0.5rem;
+        cursor: pointer;
 
-      &:hover {
-        background-color: var(--highlight-color);
+        &:hover {
+          background-color: var(--highlight-color);
+        }
       }
     }
 
-    > .partner {
+    >.partner {
       display: none;
       align-items: center;
       gap: 1rem;
       padding: 0.25rem 0.5rem;
       color: inherit;
 
-      > img {
+      >img {
         width: 16px;
       }
 
-      > div {
-        > .mark {
+      >div {
+        >.mark {
           color: limegreen;
           font-size: 0.5rem;
           width: max-content;
@@ -245,7 +216,7 @@
 
 .startpage-searchbar {
   &[data-suggest="active"]:focus-within {
-    @media (max-height: 680px) {
+    @media (max-height: 780px) {
       z-index: 21;
       position: absolute;
       top: 0;
@@ -253,10 +224,20 @@
       width: 100%;
       grid-template-rows: max-content 1fr;
       height: 100dvh;
+
+      >.suggestions {
+        margin-bottom: 0;
+        overflow: auto;
+      }
+    }
+
+    >.search-input-submit {
+      border-bottom-right-radius: 0;
+      border-bottom-left-radius: 0;
     }
   }
 
-  > * {
+  >* {
     border: 1px solid @searchbar-border-color;
 
     &:not(:first-child):not(.suggestions) {
@@ -273,24 +254,18 @@
   }
 
   @media (max-width: @screen-mobile) {
-    .search-focus-selector {
-      border: 1px solid #aaa;
-      border-top: none;
-      border-radius: 5px;
-    }
-
     .search-input-submit {
-      border: 1px solid #aaa;
+      border: 1px solid @searchbar-border-color;
       border-radius: 5px;
     }
 
-    > * {
-      border: 1px solid #aaa;
+    >*:not(.suggestions) {
+      border: 1px solid @searchbar-border-color;
       border-radius: 5px;
       min-height: 40px;
 
       &:not(:first-child) {
-        border-left: 1px solid #aaa;
+        border-left: 1px solid @searchbar-border-color;
       }
     }
   }
@@ -306,14 +281,14 @@
     .search-input {
       height: 1.9rem;
 
-      > input {
+      >input {
         scroll-margin-top: 80px;
         padding: 0 0.5rem;
       }
     }
   }
 
-  > .suggestions {
+  >.suggestions {
     display: none;
     position: absolute;
     top: 3.8rem;
@@ -323,7 +298,7 @@
   }
 
   &[data-suggest="active"]:focus-within {
-    > .suggestions {
+    >.suggestions {
       display: grid;
     }
   }
@@ -354,4 +329,4 @@
 .search-submit button {
   cursor: pointer;
   max-width: 20px;
-}
+}
\ No newline at end of file
diff --git a/metager/resources/views/parts/searchbar.blade.php b/metager/resources/views/parts/searchbar.blade.php
index 9fa3bc1ae..3676e35ef 100644
--- a/metager/resources/views/parts/searchbar.blade.php
+++ b/metager/resources/views/parts/searchbar.blade.php
@@ -23,75 +23,47 @@
 					</button>
 				</div>
 			</div>
-			<div class="suggestions" data-suggest="{{Crypt::encrypt(now()->addMinutes(2))}}" data-partners="{{ route('suggest_partner') }}" data-suggestions="{{ route('suggest_suggest') }}">
-					<div class="partners">
-						<a href="" class="partner">
-							<img src="" alt="">
-							<div>
-								<div class="title"></div>
-								<div><div class="mark">@lang('result.options.4')</div></div>
-								<div class="description"></div>
-							</div>
-						</a>
-						<a href="" class="partner">
-							<img src="" alt="">
-							<div>
-								<div class="title"></div>
-								<div><div class="mark">@lang('result.options.4')</div></div>
-								<div class="description"></div>
-							</div>
-						</a>
-					</div>
-					<button type="submit" name="eingabe" class="suggestion">
-						<img src="/img/icon-lupe.svg" alt="search">
+			<div class="suggestions" data-suggestions="{{ route('suggest_suggest') }}">
+					<div class="suggestion">
+						<button type="submit" name="eingabe"><img src="/img/icon-lupe.svg" alt="search"></button>
 						<span></span>
-					</button>
-					<button type="submit" name="eingabe" class="suggestion">
-						<img src="/img/icon-lupe.svg" alt="search">
+					</div>
+					<div class="suggestion">
+						<button type="submit" name="eingabe"><img src="/img/icon-lupe.svg" alt="search"></button>
 						<span></span>
-					</button>
-					<button type="submit" name="eingabe" class="suggestion">
-						<img src="/img/icon-lupe.svg" alt="search">
+					</div>
+					<div class="suggestion">
+						<button type="submit" name="eingabe"><img src="/img/icon-lupe.svg" alt="search"></button>
 						<span></span>
-					</button>
-					<button type="submit" name="eingabe" class="suggestion">
-						<img src="/img/icon-lupe.svg" alt="search">
+					</div>
+					<div class="suggestion">
+						<button type="submit" name="eingabe"><img src="/img/icon-lupe.svg" alt="search"></button>
 						<span></span>
-					</button>
-					<a href="" class="partner">
-						<img src="" alt="">
-						<div class="title"></div>
-						<div><div class="mark">@lang('result.options.4')</div></div>
-					</a>
-					<button type="submit" name="eingabe" class="suggestion">
-						<img src="/img/icon-lupe.svg" alt="search">
+					</div>
+					<div class="suggestion">
+						<button type="submit" name="eingabe"><img src="/img/icon-lupe.svg" alt="search"></button>
 						<span></span>
-					</button>
-					<button type="submit" name="eingabe" class="suggestion">
-						<img src="/img/icon-lupe.svg" alt="search">
+					</div>
+					<div class="suggestion">
+						<button type="submit" name="eingabe"><img src="/img/icon-lupe.svg" alt="search"></button>
 						<span></span>
-					</button>
-					<button type="submit" name="eingabe" class="suggestion">
-						<img src="/img/icon-lupe.svg" alt="search">
+					</div>
+					<div class="suggestion">
+						<button type="submit" name="eingabe"><img src="/img/icon-lupe.svg" alt="search"></button>
 						<span></span>
-					</button>
-					<button type="submit" name="eingabe" class="suggestion">
-						<img src="/img/icon-lupe.svg" alt="search">
+					</div>
+					<div class="suggestion">
+						<button type="submit" name="eingabe"><img src="/img/icon-lupe.svg" alt="search"></button>
 						<span></span>
-					</button>
-					<a href="" class="partner">
-						<img src="" alt="">
-						<div class="title"></div>
-						<div><div class="mark">@lang('result.options.4')</div></div>
-					</a>
-					<button type="submit" name="eingabe" class="suggestion">
-						<img src="/img/icon-lupe.svg" alt="search">
+					</div>
+					<div class="suggestion">
+						<button type="submit" name="eingabe"><img src="/img/icon-lupe.svg" alt="search"></button>
 						<span></span>
-					</button>
-					<button type="submit" name="eingabe" class="suggestion">
-						<img src="/img/icon-lupe.svg" alt="search">
+					</div>
+					<div class="suggestion">
+						<button type="submit" name="eingabe"><img src="/img/icon-lupe.svg" alt="search"></button>
 						<span></span>
-					</button>
+					</div>
 				</div>
 			<div class="search-hidden">
 				@if(Request::filled("token"))
diff --git a/metager/routes/web.php b/metager/routes/web.php
index ee40fb332..492d647d0 100644
--- a/metager/routes/web.php
+++ b/metager/routes/web.php
@@ -79,10 +79,7 @@ Route::withoutMiddleware([\Illuminate\Foundation\Http\Middleware\ValidateCsrfTok
         return redirect(url('impressum'));
     });
 
-    Route::group(["prefix" => 'suggest'], function () {
-        Route::get("partner", [SuggestionController::class, "partner"])->name("suggest_partner");
-        Route::get("suggest", [SuggestionController::class, "suggest"])->name("suggest_suggest");
-    });
+    Route::get("suggest", [SuggestionController::class, "suggest"])->name("suggest_suggest");
 
     Route::get('about', function () {
         return view('about')
-- 
GitLab


From d9dc764c1dd8268a9cba0015990d366b23dd48a4 Mon Sep 17 00:00:00 2001
From: Dominik Hebeler <dominik@suma-ev.de>
Date: Fri, 24 Jan 2025 15:28:38 +0100
Subject: [PATCH 3/7] fix breakpoints

---
 metager/resources/js/scriptResultPage.js      |   7 ++
 metager/resources/js/suggest.js               |  16 ++-
 .../metager/pages/resultpage/result-page.less | 106 +++++++++++-------
 .../less/metager/parts/searchbar.less         |  28 ++++-
 .../resources/less/metager/parts/sidebar.less |   1 +
 .../views/layouts/researchandtabs.blade.php   |   2 +-
 .../resources/views/parts/searchbar.blade.php |  20 ++--
 7 files changed, 119 insertions(+), 61 deletions(-)

diff --git a/metager/resources/js/scriptResultPage.js b/metager/resources/js/scriptResultPage.js
index a3fc3ea42..b3d1bbd20 100644
--- a/metager/resources/js/scriptResultPage.js
+++ b/metager/resources/js/scriptResultPage.js
@@ -1,3 +1,5 @@
+const { initializeSuggestions } = require("./suggest");
+
 require("es6-promise").polyfill();
 require("fetch-ie8");
 
@@ -11,6 +13,7 @@ function initialize() {
   enableResultSaver();
   enablePagination();
   enableABHints();
+
 }
 
 // Submit search form when filters change
@@ -390,6 +393,10 @@ function initialize() {
   });
 })();
 
+(() => {
+  initializeSuggestions();
+})();
+
 if (document.readyState == "loading") {
   document.addEventListener("DOMContentLoaded", (e) => {
     document.dispatchEvent(bootEvent);
diff --git a/metager/resources/js/suggest.js b/metager/resources/js/suggest.js
index 1d112ea72..77d04f7b1 100644
--- a/metager/resources/js/suggest.js
+++ b/metager/resources/js/suggest.js
@@ -6,6 +6,7 @@ export function initializeSuggestions() {
   let query = "";
   let suggest_timeout = null;
   let searchbar_container = document.querySelector(".searchbar");
+  let on_startpage = document.querySelector("#searchForm .startpage-searchbar") != null;
   if (!searchbar_container) {
     return;
   }
@@ -33,6 +34,7 @@ export function initializeSuggestions() {
     e.preventDefault();
     suggest();
   });
+  search_input.addEventListener("blur", e => setTimeout(() => { if (document.activeElement != search_input) searchbar_container.dataset.suggest = "inactive"; else console.log("test") }, 250));
   search_input.addEventListener("change", (e) => {
     if (search_input.value.trim() == "") {
       query = "";
@@ -76,10 +78,8 @@ export function initializeSuggestions() {
   function updateSuggestions() {
     // Enable/Disable Suggestions
     if (suggestions.length > 0) {
-      suggestions_container.style.display = "grid";
       searchbar_container.dataset.suggest = "active";
     } else {
-      suggestions_container.style.display = "none";
       searchbar_container.dataset.suggest = "inactive";
     }
 
@@ -102,16 +102,22 @@ export function initializeSuggestions() {
 
         search_button.value = suggestions[index];
         title_container.textContent = suggestions[index];
-
         if (eingabe_container) {
           title_container.onclick = e => {
+            e.preventDefault();
+            console.log("test", suggestions[index]);
             eingabe_container.value = suggestions[index] + " ";
             eingabe_container.focus();
           };
         }
       });
-    if (suggestions.length > 0) {
-      setTimeout(() => searchbar_container.scrollIntoView(false), 250);
+    if (suggestions.length > 0 && on_startpage) {
+      setTimeout(() => {
+        let rect_bounds = searchbar_container.getBoundingClientRect();
+        if (rect_bounds.top < 0 || rect_bounds.left < 0 || rect_bounds.bottom > (window.visualViewport.height || window.innerHeight || document.documentElement.clientHeight) || rect_bounds.right > (window.visualViewport.width || window.innerWidth || document.documentElement.clientWidth)) {
+          searchbar_container.scrollIntoView(true);
+        }
+      }, 250);
     }
   }
 }
diff --git a/metager/resources/less/metager/pages/resultpage/result-page.less b/metager/resources/less/metager/pages/resultpage/result-page.less
index aee2f7c13..a24a427d7 100644
--- a/metager/resources/less/metager/pages/resultpage/result-page.less
+++ b/metager/resources/less/metager/pages/resultpage/result-page.less
@@ -16,7 +16,7 @@
 // The point upon which a .screen-large logo is displayed
 @logo-size-breakpoint: (@results-width-min + @padding-small-default * 2);
 // The point upon which the sidebar opener switches place
-@sidebar-opener-breakpoint: (@results-width-max + @padding-small-default * 2 + 60px);
+@sidebar-opener-breakpoint: 920px;
 // Quicktip background color
 @quicktip-background-color: @resultpage-background-color;
 // Color of the Spruch author
@@ -924,45 +924,82 @@ a {
     border-bottom: 1px solid @metager-orange;
     box-shadow: 0px 1px 1.5px 0px rgba(0, 0, 0, 0.12),
       1px 0px 1px 0px rgba(0, 0, 0, 0.24);
-    display: flex;
+    display: grid;
+    grid-template-areas: "research-logo research-searchbar";
+    grid-template-columns: max-content 1fr;
     align-items: center;
     padding: 4px;
 
-    .resultpage-searchbar {
-      .search-input-submit {
-        background-color: @background-color;
 
-        .search-input {
-          border-bottom: 1px solid @border-color;
-          border-radius: 5px;
 
-          input {
+    >#header-logo {
+      grid-area: research-logo;
+    }
+
+    >#header-searchbar {
+      grid-area: research-searchbar;
+
+      .resultpage-searchbar {
+        .search-input-submit {
+          background-color: @background-color;
 
-            &::-webkit-search-cancel-button,
-            &::-webkit-search-decoration {
-              appearance: none;
+          .search-input {
+            border-bottom: 1px solid @border-color;
+            border-radius: 5px;
+
+            input {
+
+              &::-webkit-search-cancel-button,
+              &::-webkit-search-decoration {
+                appearance: none;
+              }
             }
           }
         }
-      }
 
-      @media (max-width: @sidebar-opener-breakpoint) {
-        .search-focus-selector {
-          border-top: none;
-          border-radius: 5px;
+        @media (max-width: @sidebar-opener-breakpoint) {
+          .search-focus-selector {
+            border-top: none;
+            border-radius: 5px;
+          }
         }
+      }
+    }
 
-        >* {
-          border-radius: 5px;
-          min-height: 40px;
+    >label.sidebar-opener {
+      display: none;
 
-          &:not(:first-child) {
-            border-left: 1px solid #aaa;
-          }
+      &:not(.close) {
+        position: initial;
+        height: 100%;
+        padding-inline: 0.5rem;
+        place-content: center;
+      }
+    }
+
+    @media(max-width: 920px) {
+      grid-template-columns: max-content 1fr max-content;
+      grid-template-areas: "research-logo research-searchbar sidebar-opener";
+
+      >label.sidebar-opener {
+        &:not(.close) {
+          display: grid;
         }
       }
     }
+
+    /* Create enough space for the search input on small screens */
+    &:has(#header-searchbar .searchbar:focus-within) {
+      grid-template-columns: 1fr;
+      grid-template-areas: "research-searchbar";
+
+      >:not(#header-searchbar) {
+        display: none;
+      }
+    }
   }
+
+
 }
 
 #foki {
@@ -1191,6 +1228,12 @@ a {
   }
 }
 
+@media(max-width: @sidebar-opener-breakpoint) {
+  body>.sidebar-opener:not(.close) {
+    display: none;
+  }
+}
+
 footer.resultPageFooter {
   max-width: @results-width-max;
 
@@ -1199,23 +1242,6 @@ footer.resultPageFooter {
   }
 }
 
-/* Searchbar Opener */
-#research-bar>.sidebar-opener {
-  display: none;
-  margin-right: 10px;
-  margin-left: 20px;
-}
-
-@media (max-width: @sidebar-opener-breakpoint) {
-  #resultpage-container>.sidebar-opener {
-    display: none;
-  }
-
-  #research-bar>.sidebar-opener {
-    display: initial;
-  }
-}
-
 /* Style-fixes for browsers that do not support grid layout */
 
 #resultpage-container {
diff --git a/metager/resources/less/metager/parts/searchbar.less b/metager/resources/less/metager/parts/searchbar.less
index 8f93353dc..bd51c4155 100644
--- a/metager/resources/less/metager/parts/searchbar.less
+++ b/metager/resources/less/metager/parts/searchbar.less
@@ -9,7 +9,6 @@
   background-color: transparent;
   color: #333;
   width: 100%;
-  width: calc(100% - 16px);
   max-width: 600px;
   margin: 0 auto;
 
@@ -17,6 +16,7 @@
     grid-template-rows: max-content 1fr;
 
     >.suggestions {
+      display: grid;
       border: 1px solid @searchbar-border-color;
       border-top: 0;
     }
@@ -216,6 +216,8 @@
 
 .startpage-searchbar {
   &[data-suggest="active"]:focus-within {
+    display: grid;
+
     @media (max-height: 780px) {
       z-index: 21;
       position: absolute;
@@ -273,10 +275,11 @@
 
 .resultpage-searchbar {
   grid-template-rows: max-content;
+  max-width: initial;
+  max-height: calc(100dvh - 12px);
 
   .search-input-submit {
     padding: 0 0.5rem;
-    margin-right: 2rem;
 
     .search-input {
       height: 1.9rem;
@@ -290,16 +293,31 @@
 
   >.suggestions {
     display: none;
-    position: absolute;
-    top: 3.8rem;
     width: 100%;
+    position: absolute;
+    top: 100%;
     left: 0;
-    right: 0;
+    border: 1px solid @searchbar-border-color;
+    border-top: 0;
+    margin: 0;
+    padding-block: 0.5rem;
   }
 
   &[data-suggest="active"]:focus-within {
     >.suggestions {
       display: grid;
+      overflow: auto;
+    }
+
+    @media(max-height: 550px) {
+      height: calc(100dvh - 30px);
+
+      >.suggestions {
+        position: initial;
+        border: 0;
+        padding-top: 0;
+        padding-bottom: 0;
+      }
     }
   }
 }
diff --git a/metager/resources/less/metager/parts/sidebar.less b/metager/resources/less/metager/parts/sidebar.less
index 219f27fe6..5904d6cb0 100644
--- a/metager/resources/less/metager/parts/sidebar.less
+++ b/metager/resources/less/metager/parts/sidebar.less
@@ -268,6 +268,7 @@
 
 .sidebar-opener {
   position: fixed;
+  grid-area: sidebar-opener;
   top: @sidebar-opener-position-top;
   right: @sidebar-opener-position-right;
   margin: 0px;
diff --git a/metager/resources/views/layouts/researchandtabs.blade.php b/metager/resources/views/layouts/researchandtabs.blade.php
index 951e299dd..efd464104 100644
--- a/metager/resources/views/layouts/researchandtabs.blade.php
+++ b/metager/resources/views/layouts/researchandtabs.blade.php
@@ -22,7 +22,7 @@
                     'request' => Request::method(),
                 ])
             </div>
-            <div class="sidebar-opener-placeholder"></div>
+            @include('parts.sidebar-opener', ['class' => 'fixed'])
         </div>
     </div>
     <div id="foki">
diff --git a/metager/resources/views/parts/searchbar.blade.php b/metager/resources/views/parts/searchbar.blade.php
index 3676e35ef..3fc7fec2f 100644
--- a/metager/resources/views/parts/searchbar.blade.php
+++ b/metager/resources/views/parts/searchbar.blade.php
@@ -24,43 +24,43 @@
 				</div>
 			</div>
 			<div class="suggestions" data-suggestions="{{ route('suggest_suggest') }}">
-					<div class="suggestion">
+					<div class="suggestion" tabindex="0">
 						<button type="submit" name="eingabe"><img src="/img/icon-lupe.svg" alt="search"></button>
 						<span></span>
 					</div>
-					<div class="suggestion">
+					<div class="suggestion" tabindex="0">
 						<button type="submit" name="eingabe"><img src="/img/icon-lupe.svg" alt="search"></button>
 						<span></span>
 					</div>
-					<div class="suggestion">
+					<div class="suggestion" tabindex="0">
 						<button type="submit" name="eingabe"><img src="/img/icon-lupe.svg" alt="search"></button>
 						<span></span>
 					</div>
-					<div class="suggestion">
+					<div class="suggestion" tabindex="0">
 						<button type="submit" name="eingabe"><img src="/img/icon-lupe.svg" alt="search"></button>
 						<span></span>
 					</div>
-					<div class="suggestion">
+					<div class="suggestion" tabindex="0">
 						<button type="submit" name="eingabe"><img src="/img/icon-lupe.svg" alt="search"></button>
 						<span></span>
 					</div>
-					<div class="suggestion">
+					<div class="suggestion" tabindex="0">
 						<button type="submit" name="eingabe"><img src="/img/icon-lupe.svg" alt="search"></button>
 						<span></span>
 					</div>
-					<div class="suggestion">
+					<div class="suggestion" tabindex="0">
 						<button type="submit" name="eingabe"><img src="/img/icon-lupe.svg" alt="search"></button>
 						<span></span>
 					</div>
-					<div class="suggestion">
+					<div class="suggestion" tabindex="0">
 						<button type="submit" name="eingabe"><img src="/img/icon-lupe.svg" alt="search"></button>
 						<span></span>
 					</div>
-					<div class="suggestion">
+					<div class="suggestion" tabindex="0">
 						<button type="submit" name="eingabe"><img src="/img/icon-lupe.svg" alt="search"></button>
 						<span></span>
 					</div>
-					<div class="suggestion">
+					<div class="suggestion" tabindex="0">
 						<button type="submit" name="eingabe"><img src="/img/icon-lupe.svg" alt="search"></button>
 						<span></span>
 					</div>
-- 
GitLab


From 46f8a13538115c98b7cad8be3557e9099d9f8da5 Mon Sep 17 00:00:00 2001
From: Dominik Hebeler <dominik@suma-ev.de>
Date: Tue, 28 Jan 2025 09:23:54 +0100
Subject: [PATCH 4/7] enable exit button on result page

---
 metager/resources/js/suggest.js               | 20 +++--
 .../less/metager/parts/searchbar.less         | 86 +++++++++++--------
 .../resources/views/parts/searchbar.blade.php |  1 +
 3 files changed, 66 insertions(+), 41 deletions(-)

diff --git a/metager/resources/js/suggest.js b/metager/resources/js/suggest.js
index 77d04f7b1..803ef4a66 100644
--- a/metager/resources/js/suggest.js
+++ b/metager/resources/js/suggest.js
@@ -24,28 +24,36 @@ export function initializeSuggestions() {
   search_input.addEventListener("keydown", clearSuggestTimeout);
   search_input.addEventListener("keyup", (e) => {
     if (e.key == "Escape") {
+      e.stopPropagation();
       e.target.blur();
     } else {
       clearSuggestTimeout();
       suggest_timeout = setTimeout(suggest, 600);
     }
   });
+  search_input.addEventListener("paste", e => {
+    e.preventDefault();
+    search_input.value = e.clipboardData.getData("text");
+    clearSuggestTimeout();
+    suggest();
+  });
   search_input.addEventListener("focusin", e => {
     e.preventDefault();
     suggest();
   });
-  search_input.addEventListener("blur", e => setTimeout(() => { if (document.activeElement != search_input) searchbar_container.dataset.suggest = "inactive"; else console.log("test") }, 250));
-  search_input.addEventListener("change", (e) => {
-    if (search_input.value.trim() == "") {
-      query = "";
-      searchbar_container.dataset.suggest = "inactive";
-    }
+  search_input.addEventListener("blur", e => {
+    clearSuggestTimeout();
+    setTimeout(() => {
+      if (document.activeElement != search_input) searchbar_container.dataset.suggest = "inactive";
+    }, 250);
   });
+
   search_input.form.addEventListener("submit", clearSuggestTimeout);
 
   function clearSuggestTimeout(e) {
     if (suggest_timeout != null) {
       clearTimeout(suggest_timeout);
+      suggest_timeout = null;
     }
   }
 
diff --git a/metager/resources/less/metager/parts/searchbar.less b/metager/resources/less/metager/parts/searchbar.less
index bd51c4155..37c5acee7 100644
--- a/metager/resources/less/metager/parts/searchbar.less
+++ b/metager/resources/less/metager/parts/searchbar.less
@@ -58,6 +58,15 @@
       }
     }
 
+    >#suggest-exit {
+      color: @text-color;
+      font-size: 2rem;
+      line-height: 100%;
+      cursor: pointer;
+      margin-top: -3px;
+      display: none;
+    }
+
     .search-input {
       flex-grow: 1;
       height: 1.5rem;
@@ -176,33 +185,6 @@
         }
       }
     }
-
-    >.partner {
-      display: none;
-      align-items: center;
-      gap: 1rem;
-      padding: 0.25rem 0.5rem;
-      color: inherit;
-
-      >img {
-        width: 16px;
-      }
-
-      >div {
-        >.mark {
-          color: limegreen;
-          font-size: 0.5rem;
-          width: max-content;
-          border: 1px solid limegreen;
-          padding: 0.1rem 0.25rem;
-          border-radius: 5px;
-        }
-      }
-
-      &:hover {
-        background-color: var(--highlight-color);
-      }
-    }
   }
 
   .search-hidden {
@@ -218,7 +200,7 @@
   &[data-suggest="active"]:focus-within {
     display: grid;
 
-    @media (max-height: 780px) {
+    @media (max-height: 700px) {
       z-index: 21;
       position: absolute;
       top: 0;
@@ -231,6 +213,14 @@
         margin-bottom: 0;
         overflow: auto;
       }
+
+      #search-key {
+        display: none;
+      }
+
+      #suggest-exit {
+        display: block;
+      }
     }
 
     >.search-input-submit {
@@ -308,15 +298,41 @@
       display: grid;
       overflow: auto;
     }
+  }
+}
 
-    @media(max-height: 550px) {
-      height: calc(100dvh - 30px);
+@media(max-height: 550px) {
 
-      >.suggestions {
-        position: initial;
-        border: 0;
-        padding-top: 0;
-        padding-bottom: 0;
+  #research-bar-container:has(.resultpage-searchbar[data-suggest="active"]:focus-within) {
+    padding: 0 !important;
+
+    >#research-bar {
+      align-items: baseline;
+      position: absolute;
+      width: 100%;
+      height: 100dvh;
+      border-bottom-color: @border-color;
+
+      >#header-searchbar,
+      >#header-searchbar>fieldset,
+      >#header-searchbar>fieldset>form,
+      >#header-searchbar>fieldset>form>.searchbar {
+        height: 100%;
+      }
+
+      .resultpage-searchbar {
+        >.suggestions {
+          position: initial;
+          border: 0;
+        }
+
+        #search-key {
+          display: none;
+        }
+
+        #suggest-exit {
+          display: block;
+        }
       }
     }
   }
diff --git a/metager/resources/views/parts/searchbar.blade.php b/metager/resources/views/parts/searchbar.blade.php
index 3fc7fec2f..8248926a7 100644
--- a/metager/resources/views/parts/searchbar.blade.php
+++ b/metager/resources/views/parts/searchbar.blade.php
@@ -11,6 +11,7 @@
 						>
 					</a>
 				</div>
+				<div id="suggest-exit">&larr;</div>
 				<div class="search-input @if(!\Request::is('/')) search-delete-js-only @endif">
 					<input type="search" id="eingabe" name="eingabe" value="@if(Request::filled("eingabe")){{Request::input("eingabe")}}@endif" @if(\Request::is('/') && !\Request::filled('mgapp')) autofocus @endif autocomplete="off" class="form-control" placeholder="{{ trans('index.placeholder') }}">
 					<button id="search-delete-btn" name="delete-search-input" type="reset" title="@lang('index.searchreset')">
-- 
GitLab


From a0a7f8602200f9fe2a56ad263ec419d9621c772a Mon Sep 17 00:00:00 2001
From: Dominik Hebeler <dominik@suma-ev.de>
Date: Tue, 4 Feb 2025 11:19:01 +0100
Subject: [PATCH 5/7] serverside suggest timeout

---
 .../Http/Controllers/StartpageController.php  | 14 ++--
 .../Http/Controllers/SuggestionController.php | 76 ++++++++-----------
 metager/app/Models/Suggestions/Serper.php     |  6 +-
 metager/app/Suggestions.php                   | 15 +++-
 metager/resources/js/suggest.js               | 15 +---
 .../resources/views/parts/searchbar.blade.php |  2 +-
 metager/resources/views/plugin.blade.php      |  1 +
 metager/routes/web.php                        |  2 +-
 8 files changed, 59 insertions(+), 72 deletions(-)

diff --git a/metager/app/Http/Controllers/StartpageController.php b/metager/app/Http/Controllers/StartpageController.php
index a452ba711..6b4a951c1 100644
--- a/metager/app/Http/Controllers/StartpageController.php
+++ b/metager/app/Http/Controllers/StartpageController.php
@@ -78,16 +78,14 @@ class StartpageController extends Controller
 
     public function loadPlugin(Request $request, $locale = "de")
     {
-        $link = action('MetaGerSearch@search', []);
-        $link .= "?";
-        $link .= "eingabe={searchTerms}";
-        $key = $request->input('key', '');
-        if (!empty($key)) {
-            $link .= "&key=" . urlencode($key);
-        }
+        $link = action('MetaGerSearch@search') . "?eingabe={searchTerms}";
+
+        $suggestLink = route('suggest') . "?query={searchTerms}";
+
         $response = Response::make(
             view('plugin')
-                ->with('link', $link),
+                ->with('link', $link)
+                ->with('suggestLink', $suggestLink),
             "200"
         );
         $response->header('Content-Type', "application/opensearchdescription+xml");
diff --git a/metager/app/Http/Controllers/SuggestionController.php b/metager/app/Http/Controllers/SuggestionController.php
index 10615cdcf..85dba2347 100644
--- a/metager/app/Http/Controllers/SuggestionController.php
+++ b/metager/app/Http/Controllers/SuggestionController.php
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
 
 use App\Localization;
 use App\Models\Authorization\Authorization;
+use App\Models\Authorization\KeyAuthorization;
 use App\Models\Result;
 use App\SearchSettings;
 use App\Suggestions;
@@ -11,69 +12,56 @@ use Cache;
 use Crypt;
 use Exception;
 use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Redis;
 
 class SuggestionController extends Controller
 {
     const CACHE_DURATION_HOURS = 6;
-    private $markets = [
-        "us" => "us(en)",
-        "ch" => "ch(de)",
-    ];
 
     public function suggest(Request $request)
     {
         $query = $request->input("query");
 
-        // Do not generate Suggestions if User turned them off
+        // Do not generate Suggestions if User turned them off        
         $settings = app(SearchSettings::class);
         if (in_array($settings->suggestions, [null, "off"])) {
             return response()->json([], 200, ["Cache-Control" => "no-cache, private"]);
         }
-        $suggestion_provider = $settings->suggestions;
 
-        $suggestions = Suggestions::fromProviderName($suggestion_provider, $query);
-        $suggestions = $suggestions->fetch();
-        return response()->json($suggestions, 200, ["Cache-Control" => "max-age=7200"]);
+        /**
+         * Delay implementation to prevent unnecessary suggestion requests while typing.
+         * The response will block for some time and any other following request will
+         * prevent the first one to be executed
+         */
+        $delay = 0.6;
+        $cache_key = $request->ip() . $request->userAgent();
+        if (app(Authorization::class) instanceof KeyAuthorization) {
+            $cache_key .= app(Authorization::class)->getToken();
+        }
+        $cache_key = "suggest:" . md5($cache_key);
 
-        $region = strtolower(Localization::getRegion());
-        if (array_key_exists($region, $this->markets)) {
-            $region = $this->markets[$region];
+        Redis::rpush($cache_key, $query);
+        Redis::del($cache_key);
+        $delay_result = Redis::blpop($cache_key, $delay);
+        if ($delay_result !== null) {
+            return response()->json(["error" => "Aborted because of newer request"], 423);
         }
-        $public_key = config("metager.metager.admitad.suggest_public_key");
 
-        $request_data = [
-            "query" => $query,
-            "market" => $region,
-            "provider" => $suggestion_provider . "-suggest"
-        ];
 
-        $cache_key = md5(json_encode($request_data));
-        $response = Cache::get($cache_key);
-        if ($response === null) {
-            $context = stream_context_create([
-                "http" => [
-                    "method" => "GET",
-                    "header" => [
-                        "Content-Type: application/json",
-                        "Authorization: Bearer $public_key"
-                    ],
-                    "user_agent" => "MetaGer",
-                    "timeout" => 2.0,
-                    "content" => null,
-                    "ignore_errors" => true
-                ]
-            ]);
-            $url = "https://apisuggests.com/api/v1/suggest?" . http_build_query($request_data);
-            $response = file_get_contents($url, false, $context);
-            $response = json_decode($response, true);
-            if (array_key_exists("suggestions", $response) && is_array($response["suggestions"]) && array_key_exists("items", $response["suggestions"]) && is_array($response["suggestions"]["items"])) {
-                Cache::put($cache_key, $response, now()->addHours(self::CACHE_DURATION_HOURS));
-            }
-        }
-        if (array_key_exists("suggestions", $response) && is_array($response["suggestions"]) && array_key_exists("items", $response["suggestions"]) && is_array($response["suggestions"]["items"])) {
-            return response()->json($response["suggestions"]["items"], 200, ["Cache-Control" => "max-age=7200"]);
+        $suggestion_provider = $settings->suggestions;
+
+
+        $cache_key = "suggestion:cache:$suggestion_provider:$query";
+        if (Cache::has($cache_key)) {
+            response()->json(Cache::get($cache_key), 200, ["Cache-Control" => "max-age=7200"]);
         } else {
-            return response()->json([], 200, ["Cache-Control" => "no-cache, private"]);
+            $suggestions = Suggestions::fromProviderName($suggestion_provider, $query);
+            $suggestions->fetch();
+
+            $suggestion_response = $suggestions->toJSON();
+            Cache::put($cache_key, $suggestion_response, now()->addDay());
+
+            return response()->json($suggestion_response, 200, ["Cache-Control" => "max-age=7200"]);
         }
     }
 
diff --git a/metager/app/Models/Suggestions/Serper.php b/metager/app/Models/Suggestions/Serper.php
index 074a9ed8b..06e9c9fb1 100644
--- a/metager/app/Models/Suggestions/Serper.php
+++ b/metager/app/Models/Suggestions/Serper.php
@@ -28,17 +28,15 @@ class Serper extends Suggestions
         return parent::fetch();
     }
 
-    protected function parseResponse(string $response): array
+    protected function parseResponse(string $response): void
     {
         try {
             $suggestion_response = json_decode($response, true);
             $result = [];
             foreach ($suggestion_response["suggestions"] as $suggestion) {
-                $result[] = $suggestion["value"];
+                $this->suggestions[] = $suggestion["value"];
             }
-            return $result;
         } catch (Exception $e) {
-            return [];
         }
     }
 }
\ No newline at end of file
diff --git a/metager/app/Suggestions.php b/metager/app/Suggestions.php
index 1bbc00b8c..b5c5767ba 100644
--- a/metager/app/Suggestions.php
+++ b/metager/app/Suggestions.php
@@ -13,6 +13,7 @@ abstract class Suggestions
 {
     public const NAME = "";
     protected string $query;
+    protected array $suggestions = [];
     /** Should the request be made as POST request. GET method is used otherwise */
     protected bool $api_method_post = false;
     protected int $api_success_response_code = 200;
@@ -47,7 +48,7 @@ abstract class Suggestions
      * Parses the server response and returns an array of suggestions
      * @return array
      */
-    abstract protected function parseResponse(string $response): array;
+    abstract protected function parseResponse(string $response): void;
 
     public function fetch()
     {
@@ -81,4 +82,16 @@ abstract class Suggestions
             return [];
         }
     }
+
+    public function toJSON(): array
+    {
+        $result = [];
+        $result[0] = $this->query;
+        $result[1] = $this->suggestions;
+        $result[2] = [];
+        foreach ($result[1] as $suggestion) {
+            $result[3][] = route("resultpage", ["eingabe" => $suggestion]);
+        }
+        return $result;
+    }
 }
\ No newline at end of file
diff --git a/metager/resources/js/suggest.js b/metager/resources/js/suggest.js
index 803ef4a66..febc46cfd 100644
--- a/metager/resources/js/suggest.js
+++ b/metager/resources/js/suggest.js
@@ -4,7 +4,6 @@
 export function initializeSuggestions() {
   let suggestions = [];
   let query = "";
-  let suggest_timeout = null;
   let searchbar_container = document.querySelector(".searchbar");
   let on_startpage = document.querySelector("#searchForm .startpage-searchbar") != null;
   if (!searchbar_container) {
@@ -21,20 +20,17 @@ export function initializeSuggestions() {
     return;
   }
 
-  search_input.addEventListener("keydown", clearSuggestTimeout);
   search_input.addEventListener("keyup", (e) => {
     if (e.key == "Escape") {
       e.stopPropagation();
       e.target.blur();
     } else {
-      clearSuggestTimeout();
-      suggest_timeout = setTimeout(suggest, 600);
+      suggest();
     }
   });
   search_input.addEventListener("paste", e => {
     e.preventDefault();
     search_input.value = e.clipboardData.getData("text");
-    clearSuggestTimeout();
     suggest();
   });
   search_input.addEventListener("focusin", e => {
@@ -50,13 +46,6 @@ export function initializeSuggestions() {
 
   search_input.form.addEventListener("submit", clearSuggestTimeout);
 
-  function clearSuggestTimeout(e) {
-    if (suggest_timeout != null) {
-      clearTimeout(suggest_timeout);
-      suggest_timeout = null;
-    }
-  }
-
   function suggest() {
     if (search_input.value.trim().length <= 3 || navigator.webdriver) {
       suggestions = [];
@@ -75,7 +64,7 @@ export function initializeSuggestions() {
     })
       .then((response) => response.json())
       .then((response) => {
-        suggestions = response;
+        suggestions = response[1];
         updateSuggestions();
       }).catch(reason => {
         suggestions = [];
diff --git a/metager/resources/views/parts/searchbar.blade.php b/metager/resources/views/parts/searchbar.blade.php
index 8248926a7..81c774d39 100644
--- a/metager/resources/views/parts/searchbar.blade.php
+++ b/metager/resources/views/parts/searchbar.blade.php
@@ -24,7 +24,7 @@
 					</button>
 				</div>
 			</div>
-			<div class="suggestions" data-suggestions="{{ route('suggest_suggest') }}">
+			<div class="suggestions" data-suggestions="{{ route('suggest') }}">
 					<div class="suggestion" tabindex="0">
 						<button type="submit" name="eingabe"><img src="/img/icon-lupe.svg" alt="search"></button>
 						<span></span>
diff --git a/metager/resources/views/plugin.blade.php b/metager/resources/views/plugin.blade.php
index 8e539ea72..2e0971412 100644
--- a/metager/resources/views/plugin.blade.php
+++ b/metager/resources/views/plugin.blade.php
@@ -4,5 +4,6 @@
         <Description>{{ trans('plugin.description') }}</Description>
         <Image width="16" height="16" type="image/x-icon">{{ url('/favicon.ico') }}</Image>
         <Url type="text/html" template="{{ $link }}" method="GET" />
+        <Url type="application/x-suggestions+json" template="{{ $suggestLink }}"/>
         <InputEncoding>UTF-8</InputEncoding>
 </OpenSearchDescription>
diff --git a/metager/routes/web.php b/metager/routes/web.php
index 492d647d0..5a90bdfea 100644
--- a/metager/routes/web.php
+++ b/metager/routes/web.php
@@ -79,7 +79,7 @@ Route::withoutMiddleware([\Illuminate\Foundation\Http\Middleware\ValidateCsrfTok
         return redirect(url('impressum'));
     });
 
-    Route::get("suggest", [SuggestionController::class, "suggest"])->name("suggest_suggest");
+    Route::get("suggest", [SuggestionController::class, "suggest"])->name("suggest");
 
     Route::get('about', function () {
         return view('about')
-- 
GitLab


From b3124eb5863f2f0b486ef0460c941baffa6f7d69 Mon Sep 17 00:00:00 2001
From: Dominik Hebeler <dominik@suma-ev.de>
Date: Thu, 20 Feb 2025 15:52:00 +0100
Subject: [PATCH 6/7] implement serverside setting storage for locationbar
 suggestions

---
 .../Http/Controllers/SettingsController.php   | 148 ++++++++++++------
 .../Http/Controllers/StartpageController.php  |  20 ++-
 .../Http/Controllers/SuggestionController.php |  69 +++++++-
 metager/app/SearchSettings.php                |  31 +++-
 metager/lang/en/settings.php                  |  29 +++-
 metager/resources/js/utility.js               |   1 +
 .../resources/less/metager/general/base.less  |   4 +
 .../less/metager/parts/settings.less          |  19 ++-
 .../views/layouts/staticPages.blade.php       |   9 +-
 .../resources/views/settings/index.blade.php  |  70 ++++++++-
 metager/routes/web.php                        |   2 +-
 11 files changed, 331 insertions(+), 71 deletions(-)

diff --git a/metager/app/Http/Controllers/SettingsController.php b/metager/app/Http/Controllers/SettingsController.php
index 092be3218..b4ca881c1 100644
--- a/metager/app/Http/Controllers/SettingsController.php
+++ b/metager/app/Http/Controllers/SettingsController.php
@@ -16,6 +16,9 @@ use LaravelLocalization;
 
 class SettingsController extends Controller
 {
+
+
+
     public function index(Request $request)
     {
         $settings = app(SearchSettings::class);
@@ -79,6 +82,12 @@ class SettingsController extends Controller
 
         $agent = new Agent();
 
+        if ($settings->suggestion_locationbar === true && $agent->isFirefox() && $agent->isDesktop() && $authorization instanceof KeyAuthorization) {
+            $suggestions_ff_plugin_desktop = true;
+        } else {
+            $suggestions_ff_plugin_desktop = false;
+        }
+
         return response(view('settings.index')
             ->with('title', trans('titles.settings', ['fokus' => $fokusName]))
             ->with('fokus', $settings->fokus)
@@ -93,6 +102,7 @@ class SettingsController extends Controller
             ->with('url', $url)
             ->with('blacklist', $blacklist)
             ->with('cookieLink', $cookieLink)
+            ->with("suggestions_ff_plugin_desktop", $suggestions_ff_plugin_desktop)
             ->with('agent', $agent)
             ->with('browser', $agent->browser())
             ->with('js', [mix('js/scriptSettings.js')]), 200, ["Cache-Control" => "no-store"]);
@@ -289,73 +299,110 @@ class SettingsController extends Controller
     {
         $fokus = $request->input('focus', '');
         $url = $request->input('url', '');
-        $secure = app()->environment("local") ? false : true;
-        // Currently only the setting for quotes is supported
-
-        $suggestions = $request->input('sg', '');
-        if (!empty($suggestions)) {
-            if ($suggestions === "off") {
-                Cookie::queue(Cookie::forever('suggestions', 'off', '/', null, $secure, false));
-            } elseif ($suggestions === "serper") {
-                Cookie::queue(Cookie::forever('suggestions', 'serper', '/', null, $secure, false));
-            }
+
+        if (self::PROCESS_GLOBAL_SETTING_CHANGE("suggestion_provider", $request->input('sg', ''))) {
+            $redirect_url = route('settings', ["focus" => $fokus, "url" => $url, "anchor" => "suggest-settings"]);
+        } else if (self::PROCESS_GLOBAL_SETTING_CHANGE("suggestion_delay", $request->input('sgd', ''))) {
+            $redirect_url = route('settings', ["focus" => $fokus, "url" => $url, "anchor" => "suggest-settings"]);
+        } else if (self::PROCESS_GLOBAL_SETTING_CHANGE("suggestion_locationbar", $request->input('sglb', ''))) {
+            $redirect_url = route('settings', ["focus" => $fokus, "url" => $url, "anchor" => "suggest-settings"]);
+        } else {
+            // All Settings behind "More Settings"
+            $redirect_url = route('settings', ["focus" => $fokus, "url" => $url, "anchor" => "more-settings"]);
+            self::PROCESS_GLOBAL_SETTING_CHANGE("self_advertisements", $request->input('self_advertisements', ''));
+            self::PROCESS_GLOBAL_SETTING_CHANGE("tiles_startpage", $request->input('tiles_startpage', ''));
+            self::PROCESS_GLOBAL_SETTING_CHANGE("zitate", $request->input('zitate', ''));
+            self::PROCESS_GLOBAL_SETTING_CHANGE("dm", $request->input('dm', ''));
+            self::PROCESS_GLOBAL_SETTING_CHANGE("nt", $request->input('nt', ''));
         }
 
-        $self_advertisements = $request->input('self_advertisements', '');
-        if (!empty($self_advertisements)) {
-            if ($self_advertisements === "off") {
+        $headers = ["Cache-Control" => "no-store"];
+        if ($request->wantsJson()) {
+            $response = $this->cookiesToJsonResponse($redirect_url);
+            return response()->json($response, 200, $headers);
+        } else {
+            return redirect($redirect_url, 302, $headers);
+        }
+    }
+
+    /**
+     * Processes a new setting value and queues/deletes necessary cookies
+     * 
+     * @param string $key
+     * @param string $value
+     * @return bool True if setting was valid and has been processed. False Otherwise
+     */
+    public static function PROCESS_GLOBAL_SETTING_CHANGE(string $key, string $value): bool
+    {
+        $settings = app(SearchSettings::class);
+        $secure = app()->environment("local") ? false : true;
+        if ($key === "suggestion_provider" && !empty($value) && in_array($value, ["off", "serper"])) {
+            if ($value === "off") {
+                Cookie::queue(Cookie::forget('suggestion_provider', '/'));
+            } elseif ($value === "serper") {
+                Cookie::queue(Cookie::forever('suggestion_provider', 'serper', '/', null, $secure, false));
+            }
+            $settings->suggestion_provider = $value;
+            SuggestionController::UPDATE_SERVER_SETTINGS();
+            return true;
+        } else if ($key === "suggestion_delay" && !empty($value) && in_array($value, ["short", "medium", "long"])) {
+            if ($value === "medium") {
+                Cookie::queue(Cookie::forget("suggestion_delay", "/"));
+            } else {
+                Cookie::queue(Cookie::forever('suggestion_delay', $value, '/', null, $secure, false));
+            }
+            $settings->suggestion_delay = $value;
+            SuggestionController::UPDATE_SERVER_SETTINGS();
+            return true;
+        } else if ($key === "suggestion_locationbar" && !empty($value)) {
+            $value = filter_var($value, FILTER_VALIDATE_BOOL);
+            if ($value) {
+                Cookie::queue(Cookie::forever('suggestion_locationbar', true, '/', null, $secure, false));
+            } else {
+                Cookie::queue(Cookie::forget("suggestion_locationbar", "/"));
+            }
+            $settings->suggestion_locationbar = $value;
+            SuggestionController::UPDATE_SERVER_SETTINGS();
+            return true;
+        } else if ($key === "self_advertisements" && !empty($value)) {
+            if ($value === "off") {
                 Cookie::queue(Cookie::forever('self_advertisements', 'off', '/', null, $secure, false));
-            } elseif ($self_advertisements === "on") {
+            } elseif ($value === "on") {
                 Cookie::queue(Cookie::forget("self_advertisements", "/"));
             }
-        }
-
-        $tiles_startpage = $request->input('tiles_startpage', '');
-        if (!empty($tiles_startpage)) {
-            if ($tiles_startpage === "off") {
+            return true;
+        } else if ($key === "tiles_startpage" && !empty($value)) {
+            if ($value === "off") {
                 Cookie::queue(Cookie::forever('tiles_startpage', 'off', '/', null, $secure, false));
-            } elseif ($tiles_startpage === "on") {
+            } elseif ($value === "on") {
                 Cookie::queue(Cookie::forget("tiles_startpage", "/"));
             }
-        }
-
-        $quotes = $request->input('zitate', '');
-        if (!empty($quotes)) {
-            if ($quotes === "off") {
+            return true;
+        } else if ($key === "zitate" && !empty($value)) {
+            if ($value === "off") {
                 Cookie::queue(Cookie::forever('zitate', 'off', '/', null, $secure, false));
-            } elseif ($quotes === "on") {
-                Cookie::queue('zitate', '', 5256000, '/', null, $secure, true);
+            } elseif ($value === "on") {
+                Cookie::queue(Cookie::forget("zitate", "/"));
             }
-        }
-
-        $darkmode = $request->input('dm');
-        if (!empty($darkmode)) {
-            if ($darkmode === "off") {
+            return true;
+        } else if ($key === "dm" && !empty($value)) {
+            if ($value === "off") {
                 Cookie::queue(Cookie::forever('dark_mode', '1', '/', null, $secure, false));
-            } elseif ($darkmode === "on") {
+            } elseif ($value === "on") {
                 Cookie::queue(Cookie::forever('dark_mode', '2', '/', null, $secure, false));
-            } elseif ($darkmode === "system") {
+            } elseif ($value === "system") {
                 Cookie::queue(Cookie::forget('dark_mode', '/'));
             }
-        }
-
-        $newTab = $request->input('nt');
-        if (!empty($newTab)) {
-            if ($newTab === "off") {
+            return true;
+        } else if ($key === "nt" && !empty($value)) {
+            if ($value === "off") {
                 Cookie::queue(Cookie::forget('new_tab', '/'));
-            } elseif ($newTab === "on") {
+            } elseif ($value === "on") {
                 Cookie::queue(Cookie::forever('new_tab', 'on', '/', null, $secure, false));
             }
+            return true;
         }
-
-        $redirect_url = route('settings', ["focus" => $fokus, "url" => $url, "anchor" => "more-settings"]);
-        $headers = ["Cache-Control" => "no-store"];
-        if ($request->wantsJson()) {
-            $response = $this->cookiesToJsonResponse($redirect_url);
-            return response()->json($response, 200, $headers);
-        } else {
-            return redirect($redirect_url, 302, $headers);
-        }
+        return false;
     }
 
     public function deleteSettings(Request $request)
@@ -372,7 +419,8 @@ class SettingsController extends Controller
             "zitate",
             "self_advertisements",
             "tiles_startpage",
-            "suggestions",
+            "suggestion_provider",
+            "suggestion_delay"
         ];
 
         $settings = Cookie::get();
diff --git a/metager/app/Http/Controllers/StartpageController.php b/metager/app/Http/Controllers/StartpageController.php
index 6b4a951c1..8921887d6 100644
--- a/metager/app/Http/Controllers/StartpageController.php
+++ b/metager/app/Http/Controllers/StartpageController.php
@@ -27,6 +27,17 @@ class StartpageController extends Controller
          */
         if ($request->filled("q")) {
             $eingabe = $request->input("q");
+
+            /**
+             * Chrome only adds opensearch descriptions when visiting the startpage
+             * turns out a redirect also works.
+             */
+            if ($eingabe === "opensearch" && $request->hasValidSignature()) {
+                if ($request->filled("url")) {
+                    return redirect($request->input("url"));
+                }
+            }
+
             return redirect(route("resultpage", ["eingabe" => $eingabe]));
         }
 
@@ -80,7 +91,13 @@ class StartpageController extends Controller
     {
         $link = action('MetaGerSearch@search') . "?eingabe={searchTerms}";
 
-        $suggestLink = route('suggest') . "?query={searchTerms}";
+        $key = $request->input("key", "");
+        if (!uuid_is_valid($key)) {
+            $key = "";
+        }
+
+        $suggestLink = route('suggest', ["key" => $key]);
+        $suggestLink .= "?query={searchTerms}";
 
         $response = Response::make(
             view('plugin')
@@ -89,6 +106,7 @@ class StartpageController extends Controller
             "200"
         );
         $response->header('Content-Type', "application/opensearchdescription+xml");
+        $response->header("Cache-Control", "no-store");
         return $response;
     }
 
diff --git a/metager/app/Http/Controllers/SuggestionController.php b/metager/app/Http/Controllers/SuggestionController.php
index 85dba2347..149ca6731 100644
--- a/metager/app/Http/Controllers/SuggestionController.php
+++ b/metager/app/Http/Controllers/SuggestionController.php
@@ -17,14 +17,15 @@ use Illuminate\Support\Facades\Redis;
 class SuggestionController extends Controller
 {
     const CACHE_DURATION_HOURS = 6;
+    const SUGGESTION_SETTINGS_SERVER_STORAGE_PREFIX = "suggestion:settings:";
 
-    public function suggest(Request $request)
+    public function suggest(Request $request, string $key = null)
     {
         $query = $request->input("query");
 
         // Do not generate Suggestions if User turned them off        
         $settings = app(SearchSettings::class);
-        if (in_array($settings->suggestions, [null, "off"])) {
+        if (in_array($settings->suggestion_provider, [null, "off"])) {
             return response()->json([], 200, ["Cache-Control" => "no-cache, private"]);
         }
 
@@ -48,7 +49,7 @@ class SuggestionController extends Controller
         }
 
 
-        $suggestion_provider = $settings->suggestions;
+        $suggestion_provider = $settings->suggestion_provider;
 
 
         $cache_key = "suggestion:cache:$suggestion_provider:$query";
@@ -65,6 +66,68 @@ class SuggestionController extends Controller
         }
     }
 
+    /**
+     * Stores user settings regarding suggestions serverside
+     * Those are used when using suggestions in the address bar
+     * 
+     * Conditions:
+     * 1. Request is authorized using a key
+     * 2. User enabled location bar suggestions in settings
+     * 
+     * @return bool - True if settings were updated and false if any precondition is not met
+     */
+    public static function UPDATE_SERVER_SETTINGS(): bool
+    {
+        $search_settings = app(SearchSettings::class);
+        $authorization = app(Authorization::class);
+
+        if (!($authorization instanceof KeyAuthorization) || empty($authorization->getToken()) || $authorization->availableTokens <= 0) {
+            return false;
+        }
+
+        if ($search_settings->suggestion_locationbar !== true) {
+            if (Cache::has(self::SUGGESTION_SETTINGS_SERVER_STORAGE_PREFIX . $authorization->getToken())) {
+                Cache::forget(self::SUGGESTION_SETTINGS_SERVER_STORAGE_PREFIX . $authorization->getToken());
+            }
+            return false;
+        }
+
+        $settings = [
+            "suggestion_provider" => $search_settings->suggestion_provider,
+            "suggestion_delay" => $search_settings->suggestion_delay,
+            "suggestion_locationbar" => $search_settings->suggestion_locationbar
+        ];
+
+        Cache::put(self::SUGGESTION_SETTINGS_SERVER_STORAGE_PREFIX . $authorization->getToken(), $settings, now()->addMonth());
+        return true;
+    }
+
+    public static function LOAD_SERVER_SETTINGS(): array|null
+    {
+        $search_settings = app(SearchSettings::class);
+        $authorization = app(Authorization::class);
+
+        if (!($authorization instanceof KeyAuthorization) || empty($authorization->getToken())) {
+            return null;
+        }
+
+        if ($search_settings->suggestion_locationbar !== true) {
+            if (Cache::has(self::SUGGESTION_SETTINGS_SERVER_STORAGE_PREFIX . $authorization->getToken())) {
+                Cache::forget(self::SUGGESTION_SETTINGS_SERVER_STORAGE_PREFIX . $authorization->getToken());
+            }
+            return null;
+        }
+        $settings = Cache::get(self::SUGGESTION_SETTINGS_SERVER_STORAGE_PREFIX . $authorization->getToken());
+        if ($settings !== null) {
+            Cache::put(self::SUGGESTION_SETTINGS_SERVER_STORAGE_PREFIX . $authorization->getToken(), $settings, now()->addMonth());
+        } else if ($authorization->availableTokens <= 0) {
+            return [
+                "suggestion_locationbar" => false
+            ];
+        }
+        return $settings;
+    }
+
     private function verifySignature(Request $request): bool
     {
         $key = $request->header("MetaGer-Key", "");
diff --git a/metager/app/SearchSettings.php b/metager/app/SearchSettings.php
index 11246524e..7e6a25877 100644
--- a/metager/app/SearchSettings.php
+++ b/metager/app/SearchSettings.php
@@ -2,6 +2,7 @@
 
 namespace App;
 
+use App\Http\Controllers\SuggestionController;
 use App\Models\Configuration\Searchengines;
 use Cookie;
 use LaravelLocalization;
@@ -34,7 +35,9 @@ class SearchSettings
     /** @var bool */
     public $tiles_startpage;
     /** @var string */
-    public $suggestions = "bing";
+    public $suggestion_provider = "bing";
+    public $suggestion_delay = "medium";
+    public $suggestion_locationbar = false;
     public $external_image_search = "metager";
 
     public $user_settings = []; // Stores user settings that are parsed
@@ -88,11 +91,29 @@ class SearchSettings
         $this->tiles_startpage = $this->getSettingValue("tiles_startpage", true);
         $this->tiles_startpage = $this->tiles_startpage !== "off" ? true : false;
 
-        $suggestions = $this->getSettingValue("suggestions", null);
-        if (in_array($suggestions, ["off", "serper"])) {
-            $this->suggestions = $suggestions;
+        $suggestion_provider = $this->getSettingValue("suggestion_provider", null);
+        if (in_array($suggestion_provider, ["off", "serper"])) {
+            $this->suggestion_provider = $suggestion_provider;
         } else {
-            $this->suggestions = null;
+            $this->suggestion_provider = null;
+        }
+
+        $suggestion_delay = $this->getSettingValue("suggestion_delay", null);
+        if (in_array($suggestion_delay, ["short", "medium", "long"])) {
+            $this->suggestion_delay = $suggestion_delay;
+        }
+
+        $suggestion_locationbar = filter_var($this->getSettingValue("suggestion_locationbar", false), FILTER_VALIDATE_BOOL);
+        $this->suggestion_locationbar = $suggestion_locationbar;
+
+        $suggestion_server_settings = SuggestionController::LOAD_SERVER_SETTINGS();
+        if ($suggestion_server_settings !== null) {
+            if (array_key_exists("suggestion_provider", $suggestion_server_settings))
+                $this->suggestion_provider = $suggestion_server_settings["suggestion_provider"];
+            if (array_key_exists("suggestion_delay", $suggestion_server_settings))
+                $this->suggestion_delay = $suggestion_server_settings["suggestion_delay"];
+            if (array_key_exists("suggestion_locationbar", $suggestion_server_settings))
+                $this->suggestion_locationbar = $suggestion_server_settings["suggestion_locationbar"];
         }
 
         if ($this->getSettingValue("quicktips") !== null) {
diff --git a/metager/lang/en/settings.php b/metager/lang/en/settings.php
index ec48e3f69..3e5b7c26a 100644
--- a/metager/lang/en/settings.php
+++ b/metager/lang/en/settings.php
@@ -30,7 +30,34 @@ return [
     'copy' => 'Copy',
     'darkmode' => 'Toggle dark mode',
     'suggestions' => [
-        "label" => 'Search suggestions',
+        'heading' => 'Search Suggestions',
+        'provider' => [
+            "label" => 'Provider',
+        ],
+        'delay' => [
+            'label' => 'Delay',
+            'description' => 'Suggestions will be loaded after this delay while typing. Ideally you chose a delay long enough so suggestions are only loaded when you stop typing.',
+            'short' => 'Short',
+            'medium' => 'Medium',
+            'long' => 'Long'
+        ],
+        'locationbar' => [
+            'label' => 'Load Suggestions in Addressbar',
+            'description' => 'Search suggestions will be loaded while typing in the adressbar of your browser additionally to the searchbox on our start- and resultpage.',
+            'no' => "No",
+            'yes' => 'Yes',
+            'hints' => 'In order for suggestions to load when typing in your addressbar the following criteria have to be met:',
+            'criteria' => [
+                'default' => 'MetaGer has to be the default searchengine in your browser settings.',
+                'other' => 'You might need to recreate the entry for MetaGer in your search settings if it was added before we added support for search suggestions',
+                'ff-desktop' => 'Due to the way Firefox handles search suggestions the searchengine entry for MetaGer in your browser settings needs to be recreated once after activating this feature. You can just right click your addressbar now and click on "Add Searchengine MetaGer..."',
+                'ff-mobile' => 'Due to the way Firefox handles search suggestions, the searchengine entry for MetaGer in your browser settings need to be recreated or modified to include the following URLs'
+            ],
+            'firefox' => [
+                'description' => 'Due to the way Firefox handles search suggestions in the locationbar, additional steps are required to activate this feature.',
+                'desktop' => ''
+            ],
+        ],
         "off" => "Disabled",
         "on" => "Enabled",
     ],
diff --git a/metager/resources/js/utility.js b/metager/resources/js/utility.js
index b9b437cee..1b2fd3be2 100644
--- a/metager/resources/js/utility.js
+++ b/metager/resources/js/utility.js
@@ -16,6 +16,7 @@ document.addEventListener("DOMContentLoaded", (event) => {
     if (copy_button) {
       copy_button.addEventListener("click", (e) => {
         // Select all the text
+        e.preventDefault();
         let key = input_field.value;
         input_field.select();
         navigator.clipboard
diff --git a/metager/resources/less/metager/general/base.less b/metager/resources/less/metager/general/base.less
index d212fa300..1d9e4dd07 100644
--- a/metager/resources/less/metager/general/base.less
+++ b/metager/resources/less/metager/general/base.less
@@ -256,6 +256,10 @@ i.fa {
   }
 }
 
+.text-left {
+  text-align: left;
+}
+
 /* Links that look like text */
 
 .mutelink {
diff --git a/metager/resources/less/metager/parts/settings.less b/metager/resources/less/metager/parts/settings.less
index 0bc3e26f0..e9e5ea2a4 100644
--- a/metager/resources/less/metager/parts/settings.less
+++ b/metager/resources/less/metager/parts/settings.less
@@ -1,11 +1,23 @@
 /* Einstellungen */
-
 @settings-abort-btn-color: white;
+
+.form-group {
+    display: grid;
+    row-gap: 1rem;
+}
+
+#searchstring,
+#suggestionstring {
+    width: 100%;
+    max-width: 450px;
+}
+
 #settings-buttons {
     margin-top: 10px;
     display: flex;
     flex-wrap: wrap;
     justify-content: end;
+
     >* {
         margin: 10px;
     }
@@ -15,6 +27,7 @@
     display: flex;
     flex-wrap: wrap;
     align-items: end;
+
     >* {
         padding: 10px;
         width: 33%;
@@ -32,19 +45,23 @@ label.select-label {
 }
 
 @media (max-width: @screen-mobile) {
+
     html,
     body,
     .wrapper,
     #settings-buttons {
         width: 100%;
     }
+
     #settings-buttons>* {
         width: 100%;
         margin: 10px 0px;
         overflow: auto;
     }
+
     #settings-selectors {
         justify-content: stretch;
+
         >* {
             width: 100%;
             margin: 10px 0px;
diff --git a/metager/resources/views/layouts/staticPages.blade.php b/metager/resources/views/layouts/staticPages.blade.php
index ba139a478..e50ea6a44 100644
--- a/metager/resources/views/layouts/staticPages.blade.php
+++ b/metager/resources/views/layouts/staticPages.blade.php
@@ -31,12 +31,13 @@
 	<link rel="apple-touch-icon" sizes="{{$matches[1]}}x{{$matches[1]}}" href="/img/favicon/{{$file}}" type="image/png">
 	@endif
 	@endforeach
-	@if(empty(Cookie::get('key')))
-	<link rel="search" type="application/opensearchdescription+xml" title="{{ trans('staticPages.opensearch') }}" href="{{  action([App\Http\Controllers\StartpageController::class, 'loadPlugin']) }}">
+	@if(app(\App\Models\Authorization\Authorization::class)->canDoAuthenticatedSearch())
+	@if(isset($suggestions_ff_plugin_desktop) && $suggestions_ff_plugin_desktop)
+	<link rel="search" type="application/opensearchdescription+xml" title="{{ trans('staticPages.opensearch') }}" href="{{  action([App\Http\Controllers\StartpageController::class, 'loadPlugin'], ["key" => app(\App\Models\Authorization\Authorization::class)->getToken()]) }}">
 	@else
-	<link rel="search" type="application/opensearchdescription+xml" title="{{ trans('staticPages.opensearch') }}" href="{{  action([App\Http\Controllers\StartpageController::class, 'loadPlugin'], ['key' => Cookie::get('key')]) }}">
+	<link rel="search" type="application/opensearchdescription+xml" title="{{ trans('staticPages.opensearch') }}" href="{{  action([App\Http\Controllers\StartpageController::class, 'loadPlugin']) }}">
+	@endif
 	@endif
-
 	<link type="text/css" rel="stylesheet" href="{{ mix('css/themes/metager.css') }}" />
 	@if (isset($css) && is_array($css))
 	@foreach($css as $cssFile)
diff --git a/metager/resources/views/settings/index.blade.php b/metager/resources/views/settings/index.blade.php
index 10fd4812d..6acb22d28 100644
--- a/metager/resources/views/settings/index.blade.php
+++ b/metager/resources/views/settings/index.blade.php
@@ -198,21 +198,81 @@
                 </form>
             </div>
         @endif
-        <div class="card" id="more-settings">
-            <h1>@lang('settings.more')</h1>
+        <div class="card" id="suggest-settings">
+            <h1>@lang('settings.suggestions.heading')</h1>
             <p>@lang('settings.hint.hint')</p>
             <form id="setting-form" action="{{ route('enableSetting') }}" method="post" class="form">
                 <input type="hidden" name="focus" value="{{ $fokus }}">
                 <input type="hidden" name="url" value="{{ $url }}">
                 <div class="form-group">
-                    <label for="sg">@lang('settings.suggestions.label')</label>
+                    <label for="sg">@lang('settings.suggestions.provider.label')</label>
                     <select name="sg" id="sg" class="form-control">
-                        <option value="off" {{ in_array(app(App\SearchSettings::class)->suggestions, [null, "off"]) ? 'disabled selected' : '' }}>
+                        <option value="off" {{ in_array(app(App\SearchSettings::class)->suggestion_provider, [null, "off"]) ? 'disabled selected' : '' }}>
                             @lang('settings.suggestions.off')</option>
-                        <option value="serper" {{ app(App\SearchSettings::class)->suggestions === 'serper' ? 'disabled selected' : '' }}>
+                        <option value="serper" {{ app(App\SearchSettings::class)->suggestion_provider === 'serper' ? 'disabled selected' : '' }}>
                            Serper</option>
                     </select>
                 </div>
+                @if(!in_array(app(App\SearchSettings::class)->suggestion_provider, [null, "off"]))
+                <div class="form-group">
+                    <label for="sgd">@lang('settings.suggestions.delay.label')</label>
+                    <div class="text-left">@lang('settings.suggestions.delay.description')</div>
+                    <select name="sgd" id="sgd" class="form-control" {{ in_array(app(App\SearchSettings::class)->suggestion_provider, [null, "off"]) ? 'disabled' : '' }}>
+                        <option value="short" {{ app(App\SearchSettings::class)->suggestion_delay === "short" ? 'disabled selected' : '' }}>
+                            @lang('settings.suggestions.delay.short')</option>
+                        <option value="medium" {{ app(App\SearchSettings::class)->suggestion_delay === "medium" ? 'disabled selected' : '' }}>
+                            @lang('settings.suggestions.delay.medium')</option>
+                        <option value="long" {{ app(App\SearchSettings::class)->suggestion_delay === "long" ? 'disabled selected' : '' }}>
+                            @lang('settings.suggestions.delay.long')</option>
+                    </select>
+                </div>
+                <div class="form-group">
+                    <label for="sglb">@lang('settings.suggestions.locationbar.label')</label>
+                    <div class="text-left">@lang('settings.suggestions.locationbar.description')</div>
+                    <select name="sglb" id="sglb" class="form-control" {{ in_array(app(App\SearchSettings::class)->suggestion_provider, [null, "off"]) ? 'disabled' : '' }} @if(app(\App\Models\Authorization\Authorization::class)->availableTokens <= 0) disabled @endif>
+                        <option value="false" {{ app(App\SearchSettings::class)->suggestion_locationbar === false ? 'disabled selected' : '' }}>
+                            @lang('settings.suggestions.locationbar.no')</option>
+                        <option value="true" {{ app(App\SearchSettings::class)->suggestion_locationbar === true ? 'disabled selected' : '' }}>
+                            @lang('settings.suggestions.locationbar.yes')</option>
+                    </select>
+                    @if(app(App\SearchSettings::class)->suggestion_locationbar === true)
+                    <div id="suggestions-ff-hint" class="text-left">
+                        <div class="warning">@lang('settings.suggestions.locationbar.hints')</div>
+                        <ul>
+                        @if((new Jenssegers\Agent\Agent())->isFirefox())
+                            @if((new Jenssegers\Agent\Agent())->isMobile())
+                        <li>
+                            <div>@lang('settings.suggestions.locationbar.criteria.ff-mobile')</div>
+                            <label for="searchstring">Such-String-URL</label>
+                            <div class="copyLink">
+                                <input id="searchstring" name="searchstring" type="text" class="copy" value="{{ route('resultpage') }}?eingabe=%s">
+                                <button class="btn btn-default">@lang('settings.copy')</button>
+                            </div>
+                            <label for="suggestionstring">Suchvorschlags-API</label>
+                            <div class="copyLink">
+                                <input id="suggestionstring" name="suggestionstring" type="text" class="copy" value="{{ route('suggest', []) }}?query=%s">
+                                <button class="btn btn-default">@lang('settings.copy')</button>
+                            </div>
+
+                        </li>
+                            @else
+                        <li>@lang('settings.suggestions.locationbar.criteria.ff-desktop')</li>
+                            @endif
+                        @endif
+                        <li>@lang('settings.suggestions.locationbar.criteria.default')</li>
+                        </ul>
+                    </div>
+                    @endif
+                </div>
+                @endif
+            </form>
+        </div>
+        <div class="card" id="more-settings">
+            <h1>@lang('settings.more')</h1>
+            <p>@lang('settings.hint.hint')</p>
+            <form id="setting-form" action="{{ route('enableSetting') }}" method="post" class="form">
+                <input type="hidden" name="focus" value="{{ $fokus }}">
+                <input type="hidden" name="url" value="{{ $url }}">
                 <div class="form-group">
                     <label for="self_advertisements">@lang('settings.self_advertisements.label')</label>
                     <select name="self_advertisements" id="self_advertisements" class="form-control">
diff --git a/metager/routes/web.php b/metager/routes/web.php
index 5a90bdfea..f183f7bf5 100644
--- a/metager/routes/web.php
+++ b/metager/routes/web.php
@@ -79,7 +79,7 @@ Route::withoutMiddleware([\Illuminate\Foundation\Http\Middleware\ValidateCsrfTok
         return redirect(url('impressum'));
     });
 
-    Route::get("suggest", [SuggestionController::class, "suggest"])->name("suggest");
+    Route::get("suggest/{key?}", [SuggestionController::class, "suggest"])->name("suggest");
 
     Route::get('about', function () {
         return view('about')
-- 
GitLab


From 6ea8ddd8b74186264733d777aa9a62a93165be16 Mon Sep 17 00:00:00 2001
From: Dominik Hebeler <dominik@suma-ev.de>
Date: Fri, 21 Feb 2025 17:08:23 +0100
Subject: [PATCH 7/7] implement payment

---
 .../Http/Controllers/SuggestionController.php | 137 ++++++++++++++----
 .../Authorization/AnonymousTokenPayment.php   |  61 +++++++-
 .../Authorization/TokenAuthorization.php      |  10 +-
 metager/app/Models/Suggestions/Serper.php     |   1 +
 .../AuthorizationServiceProvider.php          |   2 +-
 metager/app/SearchSettings.php                |  11 +-
 metager/app/Suggestions.php                   |   2 +
 metager/resources/js/suggest.js               | 101 +++++++++++--
 8 files changed, 282 insertions(+), 43 deletions(-)

diff --git a/metager/app/Http/Controllers/SuggestionController.php b/metager/app/Http/Controllers/SuggestionController.php
index 149ca6731..e6f2ad570 100644
--- a/metager/app/Http/Controllers/SuggestionController.php
+++ b/metager/app/Http/Controllers/SuggestionController.php
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
 use App\Localization;
 use App\Models\Authorization\Authorization;
 use App\Models\Authorization\KeyAuthorization;
+use App\Models\Authorization\TokenAuthorization;
 use App\Models\Result;
 use App\SearchSettings;
 use App\Suggestions;
@@ -13,6 +14,7 @@ use Crypt;
 use Exception;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Redis;
+use Predis\Pipeline\Pipeline;
 
 class SuggestionController extends Controller
 {
@@ -25,47 +27,132 @@ class SuggestionController extends Controller
 
         // Do not generate Suggestions if User turned them off        
         $settings = app(SearchSettings::class);
-        if (in_array($settings->suggestion_provider, [null, "off"])) {
+        if (empty($query) || in_array($settings->suggestion_provider, [null, "off"])) {
             return response()->json([], 200, ["Cache-Control" => "no-cache, private"]);
         }
 
-        /**
-         * Delay implementation to prevent unnecessary suggestion requests while typing.
-         * The response will block for some time and any other following request will
-         * prevent the first one to be executed
-         */
-        $delay = 0.6;
-        $cache_key = $request->ip() . $request->userAgent();
-        if (app(Authorization::class) instanceof KeyAuthorization) {
-            $cache_key .= app(Authorization::class)->getToken();
-        }
-        $cache_key = "suggest:" . md5($cache_key);
-
-        Redis::rpush($cache_key, $query);
-        Redis::del($cache_key);
-        $delay_result = Redis::blpop($cache_key, $delay);
-        if ($delay_result !== null) {
-            return response()->json(["error" => "Aborted because of newer request"], 423);
-        }
-
-
         $suggestion_provider = $settings->suggestion_provider;
 
-
         $cache_key = "suggestion:cache:$suggestion_provider:$query";
         if (Cache::has($cache_key)) {
-            response()->json(Cache::get($cache_key), 200, ["Cache-Control" => "max-age=7200"]);
+            return response()->json(Cache::get($cache_key), 200, ["Cache-Control" => "max-age=7200"]);
         } else {
             $suggestions = Suggestions::fromProviderName($suggestion_provider, $query);
-            $suggestions->fetch();
+            $authorization = app(Authorization::class);
+            $authorization->setCost($suggestions->cost);
+
+            if (!$authorization->canDoAuthenticatedSearch(true)) {
+                return response()->json(["error" => "Payment Required", "cost" => $authorization->getCost()], 402);
+            }
 
-            $suggestion_response = $suggestions->toJSON();
+            $token_data = [];
+            if ($authorization instanceof TokenAuthorization) {
+                $token_data["tokens"] = $authorization->getToken()->tokens;
+                $token_data["decitokens"] = $authorization->getToken()->decitokens;
+            }
+            try {
+                switch ($this->delay()) {
+                    case 402:
+                        return response()->json(array_merge(["error" => "Payment Required", "cost" => $authorization->getCost()], $token_data), 402);
+                    case 423:
+                        return response()->json(array_merge(["error" => "Aborted because of newer request"], $token_data), 423);
+                    case 200:
+                        $authorization->makePayment($authorization->getCost());
+                        if ($authorization instanceof TokenAuthorization) {
+                            $token_data["tokens"] = $authorization->getToken()->tokens;
+                            $token_data["decitokens"] = $authorization->getToken()->decitokens;
+                        }
+                        break;
+                    default:
+                        return response()->json(["error" => "Unexpected delay status code"], 500);
+                }
+                $suggestions->fetch();
+            } catch (Exception $e) {
+                return response()->json(array_merge(["error" => $e->getMessage()], $token_data), 500);
+            }
+
+            $suggestion_response = array_merge($suggestions->toJSON(), $token_data);
             Cache::put($cache_key, $suggestion_response, now()->addDay());
 
             return response()->json($suggestion_response, 200, ["Cache-Control" => "max-age=7200"]);
         }
     }
 
+    /**
+     * Suggestions will load after a configured time to not
+     * load them everytime a user enters a letter but rather
+     * when he pauses/stops typing.
+     * 
+     * This function accounts for race conditions.
+     * 
+     * @return int Status code of response
+     */
+    private function delay(): int
+    {
+        $settings = app(SearchSettings::class);
+        $authorization = app(Authorization::class);
+
+        $uuid = \Request::input("number", microtime(true));
+        $list = [];
+
+        if ($authorization instanceof KeyAuthorization) {
+            $cache_key = $authorization->getToken();
+        } else if ($authorization instanceof TokenAuthorization) {
+            /**
+             * @var \App\Models\Authorization\AnonymousTokenPayment
+             */
+            $token_payment = $authorization->getToken();
+            $cache_key = "";
+            foreach ($token_payment->tokens as $token) {
+                $cache_key .= $token->token;
+            }
+            foreach ($token_payment->decitokens as $token) {
+                $cache_key .= $token->token;
+            }
+        } else {
+            $cache_key = \Request::ip() . \Request::userAgent();
+        }
+        $cache_key = md5($cache_key);
+
+        $expiration = now()->addMilliseconds($settings->suggestion_delay);
+        $result = Redis::pipeline(function (Pipeline $pipe) use ($cache_key, $uuid, $expiration) {
+            $pipe->rpush($cache_key, $uuid);
+            $pipe->lrange($cache_key, 0, -1);
+            $pipe->pexpireat($cache_key, $expiration->getTimestampMs() + 50000);
+
+        });
+
+        // Abort all but the newest request
+        $list = $result[1];
+        if (sizeof($list) > 0) {
+            $newest = max($list);
+            foreach ($list as $suggest_request) {
+                if ($suggest_request !== $newest) {
+                    $key = "suggest:delay:request:$suggest_request";
+                    Redis::rpush($key, 423);
+                    Redis::pexpireat($key, $expiration->getTimestampMs());
+                }
+            }
+        }
+
+        $delay_result = Redis::blpop("suggest:delay:request:$uuid", now()->diffInMilliseconds($expiration, true) / 1000);
+        if ($delay_result !== null) {
+            return filter_var($delay_result[1], FILTER_VALIDATE_INT);
+        } else {
+            if ($authorization instanceof TokenAuthorization && sizeof($list) > 0) {
+                // Abort all other requests that use this token because it will be used up by this request
+                foreach ($list as $suggest_request) {
+                    if ($suggest_request !== $newest) {
+                        $key = "suggest:delay:request:$suggest_request";
+                        Redis::rpush($key, 402);
+                        Redis::pexpireat($key, $expiration->getTimestampMs());
+                    }
+                }
+            }
+            return 200;
+        }
+    }
+
     /**
      * Stores user settings regarding suggestions serverside
      * Those are used when using suggestions in the address bar
diff --git a/metager/app/Models/Authorization/AnonymousTokenPayment.php b/metager/app/Models/Authorization/AnonymousTokenPayment.php
index 8bdb9c786..3f2dc1ee8 100644
--- a/metager/app/Models/Authorization/AnonymousTokenPayment.php
+++ b/metager/app/Models/Authorization/AnonymousTokenPayment.php
@@ -99,6 +99,32 @@ class AnonymousTokenPayment
         if (sizeof($this->tokens) === 0 && sizeof($this->decitokens) === 0) {
             return false;
         }
+
+        $payload = [
+            "tokens" => [],
+            "decitokens" => []
+        ];
+        $tokens = $this->tokens;
+        $this->tokens = [];
+        foreach ($tokens as $token) {
+            $check_result = $this->isChecked($token, false);
+            if ($check_result === null) {
+                $payload["tokens"][] = $token;
+            } else if ($check_result === true) {
+                $this->tokens[] = $token;
+            }
+        }
+        $decitokens = $this->decitokens;
+        $this->decitokens = [];
+        foreach ($decitokens as $token) {
+            $check_result = $this->isChecked($token, true);
+            if ($check_result === null) {
+                $payload["decitokens"][] = $token;
+            } else if ($check_result === true) {
+                $this->decitokens[] = $token;
+            }
+        }
+
         $url = $this->key_api_server . "/token/check";
 
         $ch = curl_init($url);
@@ -110,7 +136,7 @@ class AnonymousTokenPayment
             ],
             CURLOPT_TIMEOUT => 5,
             CURLOPT_POST => true,
-            CURLOPT_POSTFIELDS => json_encode(["tokens" => $this->tokens, "decitokens" => $this->decitokens]),
+            CURLOPT_POSTFIELDS => json_encode($payload),
             CURLOPT_USERAGENT => "MetaGer"
         ]);
 
@@ -118,10 +144,16 @@ class AnonymousTokenPayment
         $response_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
 
         if ($response_code === 200) {
+            foreach ($payload["tokens"] as $token) {
+                $this->tokens[] = $token;
+                $this->markChecked($token, false, true);
+            }
+            foreach ($payload["decitokens"] as $token) {
+                $this->decitokens[] = $token;
+                $this->markChecked($token, true, true);
+            }
             return true;
         } elseif ($response_code === 422) {
-            $this->tokens = [];
-            $this->decitokens = [];
             $result = json_decode($result);
             if ($result === null) {
                 return false;
@@ -140,7 +172,17 @@ class AnonymousTokenPayment
                 foreach ($tokens as $token) {
                     if ($token->status === "ok") {
                         if ($error->param === "tokens") {
+                            $this->markChecked($token, false, true);
                             $this->tokens[] = new Token($token->token, $token->signature, $token->date);
+                        } elseif ($error->param === "decitokens") {
+                            $this->markChecked($token, true, true);
+                            $this->decitokens[] = new Token($token->token, $token->signature, $token->date);
+                        }
+                    } else {
+                        if ($error->param === "tokens") {
+                            $this->markChecked($token, false, false);
+                        } elseif ($error->param === "decitokens") {
+                            $this->markChecked($token, true, false);
                         }
                     }
                 }
@@ -376,6 +418,9 @@ class AnonymousTokenPayment
 
     private function updateCookie()
     {
+        if (Request::wantsJson()) {
+            return;
+        }
         if (sizeof($this->tokens) === 0) {
             Cookie::queue(Cookie::forget("tokens", "/", null));
         } else {
@@ -443,6 +488,16 @@ class AnonymousTokenPayment
         return new AnonymousTokenPayment($cost, $tokens, $decitokens, $payment_id, $payment_uid, $key, $credits);
     }
 
+    private function markChecked(Token $token, bool $decitoken = false, $valid)
+    {
+        Cache::put($decitoken ? "decitoken" : "token" . ":valid:" . md5($token->token . $token->signature . $token->date), true, now()->addMinutes(5));
+    }
+
+    private function isChecked(Token $token, bool $decitoken = false): bool
+    {
+        return Cache::get($decitoken ? "decitoken" : "token" . ":valid:" . md5($token->token . $token->signature . $token->date), null);
+    }
+
     /**
      * Tries to convert a json decoded token object into a Token class 
      * @param object $token
diff --git a/metager/app/Models/Authorization/TokenAuthorization.php b/metager/app/Models/Authorization/TokenAuthorization.php
index 739482fd8..bdecc6f65 100644
--- a/metager/app/Models/Authorization/TokenAuthorization.php
+++ b/metager/app/Models/Authorization/TokenAuthorization.php
@@ -16,7 +16,7 @@ class TokenAuthorization extends Authorization
     private $tokenauthorization_header;
     public ?AnonymousTokenPayment $token_payment = null;
 
-    public function __construct(string $tokenauthorization, string|null $tokenString = null, string|null $decitokenString = null, string|null $payment_id = null, string|null $payment_uid = null)
+    public function __construct(string|null $tokenauthorization, string|null $tokenString = null, string|null $decitokenString = null, string|null $payment_id = null, string|null $payment_uid = null)
     {
         parent::__construct();
         $this->tokenauthorization_header = $tokenauthorization;
@@ -39,6 +39,14 @@ class TokenAuthorization extends Authorization
             $this->availableTokens = $this->token_payment->getAvailableTokenCount();
         }
 
+        $decitokenJson = json_decode($decitokenString);
+        if (is_array($decitokenJson)) {
+            foreach ($decitokenJson as $token) {
+                $this->token_payment->addJSONDeciToken($token);
+            }
+            $this->availableTokens = $this->token_payment->getAvailableTokenCount();
+        }
+
         $this->token_payment->checkTokens();
         $this->availableTokens = $this->token_payment->getAvailableTokenCount();
 
diff --git a/metager/app/Models/Suggestions/Serper.php b/metager/app/Models/Suggestions/Serper.php
index 06e9c9fb1..45957370c 100644
--- a/metager/app/Models/Suggestions/Serper.php
+++ b/metager/app/Models/Suggestions/Serper.php
@@ -8,6 +8,7 @@ use App\Suggestions;
 class Serper extends Suggestions
 {
     public const NAME = "serper";
+    public $cost = 0.2;
 
     public function __construct(string $query)
     {
diff --git a/metager/app/Providers/AuthorizationServiceProvider.php b/metager/app/Providers/AuthorizationServiceProvider.php
index 802109c0d..432017e46 100644
--- a/metager/app/Providers/AuthorizationServiceProvider.php
+++ b/metager/app/Providers/AuthorizationServiceProvider.php
@@ -54,7 +54,7 @@ class AuthorizationServiceProvider extends ServiceProvider
             $payment_uid = Request::header("anonymous-token-payment-uid");
         }
 
-        if ($key === "" && ($tokens !== null || $tokenauthorization !== null)) {
+        if ($key === "" && ($tokens !== null || $decitokens !== null || $tokenauthorization !== null)) {
             $this->app->singleton(Authorization::class, function ($app) use ($tokens, $tokenauthorization, $decitokens, $payment_id, $payment_uid) {
                 return new TokenAuthorization(tokenString: $tokens, decitokenString: $decitokens, tokenauthorization: $tokenauthorization, payment_id: $payment_id, payment_uid: $payment_uid);
             });
diff --git a/metager/app/SearchSettings.php b/metager/app/SearchSettings.php
index 7e6a25877..701435652 100644
--- a/metager/app/SearchSettings.php
+++ b/metager/app/SearchSettings.php
@@ -36,7 +36,8 @@ class SearchSettings
     public $tiles_startpage;
     /** @var string */
     public $suggestion_provider = "bing";
-    public $suggestion_delay = "medium";
+    /** @var int */
+    public $suggestion_delay = 600;
     public $suggestion_locationbar = false;
     public $external_image_search = "metager";
 
@@ -98,9 +99,13 @@ class SearchSettings
             $this->suggestion_provider = null;
         }
 
-        $suggestion_delay = $this->getSettingValue("suggestion_delay", null);
+        $suggestion_delay = $this->getSettingValue("suggestion_delay", "medium");
         if (in_array($suggestion_delay, ["short", "medium", "long"])) {
-            $this->suggestion_delay = $suggestion_delay;
+            $this->suggestion_delay = match ($suggestion_delay) {
+                "short" => 400,
+                "medium" => 600,
+                "long" => 800
+            };
         }
 
         $suggestion_locationbar = filter_var($this->getSettingValue("suggestion_locationbar", false), FILTER_VALIDATE_BOOL);
diff --git a/metager/app/Suggestions.php b/metager/app/Suggestions.php
index b5c5767ba..3fbfaee55 100644
--- a/metager/app/Suggestions.php
+++ b/metager/app/Suggestions.php
@@ -32,6 +32,8 @@ abstract class Suggestions
     protected array $api_get_parameters = [];
     protected array $api_header = [];
 
+    public $cost = 0;
+
 
     abstract public function __construct(string $query);
     public static function fromProviderName(string $provider, string $query): Suggestions|null
diff --git a/metager/resources/js/suggest.js b/metager/resources/js/suggest.js
index febc46cfd..f1120beac 100644
--- a/metager/resources/js/suggest.js
+++ b/metager/resources/js/suggest.js
@@ -1,3 +1,5 @@
+import { getToken, putToken } from "./messaging";
+
 /**
  * MetaGers basic suggestion module
  */
@@ -6,6 +8,7 @@ export function initializeSuggestions() {
   let query = "";
   let searchbar_container = document.querySelector(".searchbar");
   let on_startpage = document.querySelector("#searchForm .startpage-searchbar") != null;
+
   if (!searchbar_container) {
     return;
   }
@@ -46,32 +49,110 @@ export function initializeSuggestions() {
 
   search_input.form.addEventListener("submit", clearSuggestTimeout);
 
-  function suggest() {
-    if (search_input.value.trim().length <= 3 || navigator.webdriver) {
+  async function suggest(cost = 0, iteration = 1) {
+    console.log(cost);
+
+    if (iteration > 2 || navigator.webdriver) {
       suggestions = [];
       updateSuggestions();
       return;
     }
+    let token_header = null;
+    let decitoken_header = null;
+    if (cost > 0) {
+      let tokens = await getAnonymousTokens(cost);
+      token_header = [];
+      let index = 0;
+      while (cost >= 1) {
+        if (tokens.tokens.length >= (index + 1)) {
+          token_header[index] = tokens.tokens[index];
+          cost -= 1;
+        } else {
+          break;
+        }
+        index++;
+      }
+      decitoken_header = [];
+      index = 0;
+      while (cost > 0) {
+        if (tokens.decitokens.length >= (index + 1)) {
+          decitoken_header[index] = tokens.decitokens[index];
+          cost -= 0.1;
+        }
+        index++;
+      }
+    }
     if (search_input.value.trim() == query) {
       updateSuggestions();
       return;
-    } else {
-      query = search_input.value.trim();
     }
 
-    fetch(suggestion_url + "?query=" + encodeURIComponent(query), {
+    return fetch(suggestion_url + "?query=" + encodeURIComponent(search_input.value.trim()), {
       method: "GET",
+      headers: {
+        Accept: "application/json",
+        tokens: JSON.stringify(token_header),
+        decitokens: JSON.stringify(decitoken_header),
+      }
     })
-      .then((response) => response.json())
-      .then((response) => {
-        suggestions = response[1];
-        updateSuggestions();
-      }).catch(reason => {
+      .then(async (response) => {
+        let status = response.status;
+        let json_response = await response.json();
+        await recycleTokens(json_response);
+
+        console.log(status, status == 402);
+        switch (+status) {
+          case 200:
+            query = search_input.value.trim();
+            suggestions = json_response[1];
+            updateSuggestions();
+            return;
+          case 423:
+            break;
+          case 402:
+            console.log(json_response);
+            return suggest(json_response.cost, iteration + 1);
+        }
+        //return response.json()
+      })
+      .catch(reason => {
         suggestions = [];
         updateSuggestions();
       });
   }
 
+  async function recycleTokens(json_response) {
+    let recycleTokens = {
+      tokens: {
+        tokens: [],
+        decitokens: []
+      }
+    };
+    if (json_response.hasOwnProperty("tokens")) {
+      recycleTokens.tokens.tokens = json_response.tokens;
+    }
+    if (json_response.hasOwnProperty("decitokens")) {
+      recycleTokens.tokens.decitokens = json_response.decitokens;
+    }
+
+    if (recycleTokens.tokens.tokens.length > 0 || recycleTokens.tokens.decitokens.length > 0) {
+      return putToken(recycleTokens);
+    }
+  }
+
+  async function getAnonymousTokens(cost) {
+    return getToken({
+      cost: cost,
+      missing: cost,
+      tokens: {
+        tokens: [],
+        decitokens: [],
+      }
+    }).then(newtokens => {
+      return newtokens.tokens;
+    })
+  }
+
   function updateSuggestions() {
     // Enable/Disable Suggestions
     if (suggestions.length > 0) {
-- 
GitLab