Compare commits

...

8 Commits
v2.7.6 ... 2.x

Author SHA1 Message Date
KodeStar
7861ae1512 Merge pull request #1524 from KodeStar/remove_dropdown_on_single_provider
Some checks failed
Mark stale issues and pull requests / stale (push) Has been cancelled
Remove search provider dropdown when there's only a single provider
2025-11-11 12:05:43 +00:00
Chris Hunt
66dfe95c9f Fix lint issue 2025-11-11 12:02:53 +00:00
Chris Hunt
130661bd34 Remove search provider dropdown when there's only a single provider
Resolves #1509
2025-11-11 12:00:41 +00:00
KodeStar
900fc83e79 Merge pull request #1523 from KodeStar/add_autocomplete_suggestions
Add autocomplete suggestions support
2025-11-11 11:43:55 +00:00
Chris Hunt
4f30332854 Fix lint issues 2025-11-11 11:42:38 +00:00
Chris Hunt
852c231724 Add autocomplete suggestions support and added to bing, duckduckgo, and google 2025-11-11 11:39:06 +00:00
KodeStar
045bdf0deb Merge pull request #1507 from KodeStar/taglist
Some checks failed
Mark stale issues and pull requests / stale (push) Has been cancelled
Fix tag list url when tags are treated as tags
2025-09-16 09:53:06 +01:00
Chris Hunt
755c3e59e1 Fix tag list url when tags are treated as tags 2025-09-16 09:50:03 +01:00
13 changed files with 7090 additions and 16 deletions

View File

@@ -4,9 +4,11 @@ namespace App\Http\Controllers;
use App\Search;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Redirector;
use Illuminate\Support\Facades\Http;
class SearchController extends Controller
{
@@ -41,4 +43,97 @@ class SearchController extends Controller
abort(404, 'Provider type not supported');
}
/**
* Get autocomplete suggestions for a search query
*
* @return JsonResponse
*/
public function autocomplete(Request $request)
{
$requestprovider = $request->input('provider');
$query = $request->input('q');
if (!$query || trim($query) === '') {
return response()->json([]);
}
$provider = Search::providerDetails($requestprovider);
if (!$provider || !isset($provider->autocomplete)) {
return response()->json([]);
}
// Replace {query} placeholder with actual query
$autocompleteUrl = str_replace('{query}', urlencode($query), $provider->autocomplete);
try {
$response = Http::timeout(5)->get($autocompleteUrl);
if ($response->successful()) {
$data = $response->body();
// Parse the response based on provider
$suggestions = $this->parseAutocompleteResponse($data, $provider->id);
return response()->json($suggestions);
}
} catch (\Exception $e) {
// Return empty array on error
return response()->json([]);
}
return response()->json([]);
}
/**
* Parse autocomplete response based on provider format
*
* @param string $data
* @param string $providerId
* @return array
*/
private function parseAutocompleteResponse($data, $providerId)
{
$suggestions = [];
switch ($providerId) {
case 'google':
// Google returns XML format
if (strpos($data, '<?xml') === 0) {
$xml = simplexml_load_string($data);
if ($xml && isset($xml->CompleteSuggestion)) {
foreach ($xml->CompleteSuggestion as $suggestion) {
if (isset($suggestion->suggestion['data'])) {
$suggestions[] = (string) $suggestion->suggestion['data'];
}
}
}
}
break;
case 'bing':
case 'ddg':
// Bing and DuckDuckGo return JSON array format
$json = json_decode($data, true);
if (is_array($json) && isset($json[1]) && is_array($json[1])) {
$suggestions = $json[1];
}
break;
default:
// Try to parse as JSON array
$json = json_decode($data, true);
if (is_array($json)) {
if (isset($json[1]) && is_array($json[1])) {
$suggestions = $json[1];
} else {
$suggestions = $json;
}
}
break;
}
return $suggestions;
}
}

View File

@@ -101,6 +101,8 @@ class TagController extends Controller
$data['tag'] = $item->id;
$data['all_apps'] = $item->children;
$data['taglist'] = Item::ofType('tag')->where('id', '>', 0)->orderBy('title', 'asc')->get();
return view('welcome', $data);
}

View File

@@ -111,16 +111,31 @@ abstract class Search
if ((bool) $user_search_provider) {
$name = 'app.options.'.$user_search_provider;
$provider = self::providerDetails($user_search_provider);
$providers = self::providers();
$providerCount = count($providers);
// If there's only one provider, use its key instead of the user's setting
if ($providerCount === 1) {
$user_search_provider = $providers->keys()->first();
}
$output .= '<div class="searchform">';
$output .= '<form action="'.url('search').'"'.getLinkTargetAttribute().' method="get">';
$output .= '<div id="search-container" class="input-container">';
$output .= '<select name="provider">';
foreach (self::providers() as $key => $searchprovider) {
$selected = ((string) $key === (string) $user_search_provider) ? ' selected="selected"' : '';
$output .= '<option value="'.$key.'"'.$selected.'>'.$searchprovider['name'].'</option>';
// Only show dropdown if there's more than one provider
if ($providerCount > 1) {
$output .= '<select name="provider">';
foreach ($providers as $key => $searchprovider) {
$selected = ((string) $key === (string) $user_search_provider) ? ' selected="selected"' : '';
$output .= '<option value="'.$key.'"'.$selected.'>'.$searchprovider['name'].'</option>';
}
$output .= '</select>';
} else {
// Hidden input for single provider
$output .= '<input type="hidden" name="provider" value="'.$user_search_provider.'" />';
}
$output .= '</select>';
$output .= '<input type="text" name="q" value="'.e(Input::get('q') ?? '').'" class="homesearch" autofocus placeholder="'.__('app.settings.search').'..." />';
$output .= '<button type="submit">'.ucwords(__('app.settings.search')).'</button>';
$output .= '</div>';

View File

@@ -5,7 +5,7 @@ use Illuminate\Support\Facades\Facade;
return [
'version' => '2.7.6',
'version' => '2.7.7',
'appsource' => env('APP_SOURCE', 'https://appslist.heimdall.site/'),

2185
public/css/app.css vendored

File diff suppressed because one or more lines are too long

4637
public/js/app.js vendored

File diff suppressed because one or more lines are too long

1
public/js/dummy.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,4 @@
{
"/js/dummy.js": "/js/dummy.js?id=daec5f3b283a510837bec36ca3868a54",
"/css/app.css": "/css/app.css?id=8e5c9ae35dd160a37c9d33d663f996b9",
"/js/app.js": "/js/app.js?id=19052619246fec368cad13937c62d850"
"/css/app.css": "/css/app.css?id=271cb5f5a1f91d0a6dfbc65e374ffc14",
"/js/app.js": "/js/app.js?id=2ebeb753597d1cbbf88d8bc652e4af5b"
}

View File

@@ -108,11 +108,90 @@ $.when($.ready).then(() => {
}
});
// Autocomplete functionality
let autocompleteTimeout = null;
let currentAutocompleteRequest = null;
function hideAutocomplete() {
$("#search-autocomplete").remove();
}
function showAutocomplete(suggestions, inputElement) {
hideAutocomplete();
if (!suggestions || suggestions.length === 0) {
return;
}
const $input = $(inputElement);
const position = $input.position();
const width = $input.outerWidth();
const $autocomplete = $('<div id="search-autocomplete"></div>');
suggestions.forEach((suggestion) => {
const $item = $('<div class="autocomplete-item"></div>')
.text(suggestion)
.on("click", () => {
$input.val(suggestion);
hideAutocomplete();
$input.closest("form").submit();
});
$autocomplete.append($item);
});
$autocomplete.css({
position: "absolute",
top: `${position.top + $input.outerHeight()}px`,
left: `${position.left}px`,
width: `${width}px`,
});
$input.closest("#search-container").append($autocomplete);
}
function fetchAutocomplete(query, provider) {
// Cancel previous request if any
if (currentAutocompleteRequest) {
currentAutocompleteRequest.abort();
}
if (!query || query.trim().length < 2) {
hideAutocomplete();
return;
}
currentAutocompleteRequest = $.ajax({
url: `${base}search/autocomplete`,
method: "GET",
data: {
q: query,
provider,
},
success(data) {
const inputElement = $("#search-container input[name=q]")[0];
showAutocomplete(data, inputElement);
},
error() {
hideAutocomplete();
},
complete() {
currentAutocompleteRequest = null;
},
});
}
$("#search-container")
.on("input", "input[name=q]", function () {
const search = this.value;
const items = $("#sortable").find(".item-container");
if ($("#search-container select[name=provider]").val() === "tiles") {
// Get provider from either select or hidden input
const provider =
$("#search-container select[name=provider]").val() ||
$("#search-container input[name=provider]").val();
if (provider === "tiles") {
hideAutocomplete();
if (search.length > 0) {
items.hide();
items
@@ -126,6 +205,12 @@ $.when($.ready).then(() => {
}
} else {
items.show();
// Debounce autocomplete requests
clearTimeout(autocompleteTimeout);
autocompleteTimeout = setTimeout(() => {
fetchAutocomplete(search, provider);
}, 300);
}
})
.on("change", "select[name=provider]", function () {
@@ -147,9 +232,24 @@ $.when($.ready).then(() => {
} else {
$("#search-container button").show();
items.show();
hideAutocomplete();
}
});
// Hide autocomplete when clicking outside
$(document).on("click", (e) => {
if (!$(e.target).closest("#search-container").length) {
hideAutocomplete();
}
});
// Hide autocomplete on Escape key
$(document).on("keydown", (e) => {
if (e.key === "Escape") {
hideAutocomplete();
}
});
$("#search-container select[name=provider]").trigger("change");
$("#app")

View File

@@ -926,6 +926,12 @@ div.create {
max-width: 620px;
position: relative;
z-index: 4;
// Reduce width when there's no select dropdown (only has hidden input)
&:has(input[name="provider"][type="hidden"]) {
max-width: 520px;
}
form {
width: 100%;
}
@@ -933,7 +939,6 @@ div.create {
background: white;
border-radius: 5px;
box-shadow: 0px 0px 5px 0 rgba(0,0,0,0.4);
overflow: hidden;
position: relative;
display: flex;
@@ -945,6 +950,11 @@ div.create {
width: 100%;
background: transparent;
}
// When there's no select dropdown, round the input's left corners
input[name="q"]:first-child {
border-top-left-radius: 5px;
border-bottom-left-radius: 5px;
}
button {
position: absolute;
right: 0px;
@@ -965,7 +975,42 @@ div.create {
background: #f5f5f5;
border: none;
border-right: 1px solid #ddd;
border-top-left-radius: 5px;
border-bottom-left-radius: 5px;
}
// When select exists, remove input's left border radius
select ~ input[name="q"] {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
#search-autocomplete {
position: absolute;
z-index: 1000;
background: white;
border: 1px solid #ddd;
border-top: none;
border-radius: 0 0 5px 5px;
box-shadow: 0px 4px 8px 0 rgba(0,0,0,0.2);
max-height: 300px;
overflow-y: auto;
.autocomplete-item {
padding: 12px 15px;
cursor: pointer;
font-size: 15px;
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.2s ease;
&:last-child {
border-bottom: none;
}
&:hover {
background-color: #f5f5f5;
}
}
}
.ui-autocomplete {

View File

@@ -75,6 +75,7 @@ Route::post('test_config', [ItemController::class,'testConfig'])->name('test_con
Route::get('get_stats/{id}', [ItemController::class,'getStats'])->name('get_stats');
Route::get('/search', [SearchController::class,'index'])->name('search');
Route::get('/search/autocomplete', [SearchController::class,'autocomplete'])->name('search.autocomplete');
Route::get('view/{name_view}', function ($name_view) {
return view('SupportedApps::'.$name_view)->render();

View File

@@ -18,6 +18,7 @@ bing:
method: get
target: _blank
query: q
autocomplete: https://api.bing.com/osjson.aspx?query={query}
ddg:
id: ddg
@@ -26,6 +27,7 @@ ddg:
method: get
target: _blank
query: q
autocomplete: https://duckduckgo.com/ac/?q={query}&type=list
google:
id: google
@@ -34,6 +36,7 @@ google:
method: get
target: _blank
query: q
autocomplete: https://suggestqueries.google.com/complete/search?output=toolbar&hl=en&q={query}
startpage:
id: startpage

1
webpack.mix.js vendored
View File

@@ -12,7 +12,6 @@ const mix = require("laravel-mix");
*/
mix
.js("resources/assets/js/app.js", "public/js/dummy.js")
.babel(
[
"node_modules/sortablejs/Sortable.min.js",