mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
Add autocomplete for search when using manticore
This commit is contained in:
@@ -9,8 +9,21 @@ DB_DATABASE=nntmux
|
||||
|
||||
COMPOSER_AUTH='{"github-oauth": {"github.com": "YOUR GITHUB TOKEN"}}'
|
||||
|
||||
# ManticoreSearch Configuration
|
||||
MANTICORESEARCH_HOST=localhost
|
||||
MANTICORESEARCH_PORT=9308
|
||||
# For high availability, set multiple hosts (comma-separated): "host1:9308,host2:9308"
|
||||
MANTICORESEARCH_HOSTS=
|
||||
MANTICORESEARCH_RETRIES=2
|
||||
# Autocomplete settings
|
||||
MANTICORESEARCH_AUTOCOMPLETE_ENABLED=true
|
||||
MANTICORESEARCH_AUTOCOMPLETE_MIN_LENGTH=2
|
||||
MANTICORESEARCH_AUTOCOMPLETE_MAX_RESULTS=10
|
||||
MANTICORESEARCH_AUTOCOMPLETE_FUZZINESS=1
|
||||
MANTICORESEARCH_AUTOCOMPLETE_CACHE=10
|
||||
# Suggest/spell correction settings
|
||||
MANTICORESEARCH_SUGGEST_ENABLED=true
|
||||
MANTICORESEARCH_SUGGEST_MAX_EDITS=4
|
||||
|
||||
ELASTICSEARCH_HOST=localhost
|
||||
ELASTICSEARCH_PORT=9200
|
||||
|
||||
@@ -9,6 +9,10 @@ use Illuminate\Support\Facades\Log;
|
||||
use Manticoresearch\Client;
|
||||
use Manticoresearch\Exceptions\ResponseException;
|
||||
use Manticoresearch\Exceptions\RuntimeException;
|
||||
use Manticoresearch\Query\BoolQuery;
|
||||
use Manticoresearch\Query\In;
|
||||
use Manticoresearch\Query\MatchQuery;
|
||||
use Manticoresearch\Query\Range;
|
||||
use Manticoresearch\Search;
|
||||
|
||||
/**
|
||||
@@ -67,14 +71,12 @@ class ManticoreSearch
|
||||
|
||||
/**
|
||||
* Establishes a connection to ManticoreSearch HTTP port.
|
||||
* Supports single host or multiple hosts for high availability.
|
||||
*/
|
||||
public function __construct(?Client $client = null)
|
||||
{
|
||||
$this->config = $this->loadConfig();
|
||||
$this->connection = [
|
||||
'host' => $this->config['host'],
|
||||
'port' => $this->config['port'],
|
||||
];
|
||||
$this->connection = $this->buildConnectionConfig();
|
||||
|
||||
$this->manticoreSearch = $client ?? new Client($this->connection);
|
||||
$this->cli = new ColorCLI;
|
||||
@@ -84,6 +86,46 @@ class ManticoreSearch
|
||||
$this->cacheMinutes = $this->config['cache_minutes'] ?? self::DEFAULT_CACHE_MINUTES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build connection configuration supporting single or multiple hosts.
|
||||
*/
|
||||
private function buildConnectionConfig(): array
|
||||
{
|
||||
// Check for multiple hosts configuration
|
||||
$hostsString = $this->config['hosts'] ?? '';
|
||||
|
||||
if (! empty($hostsString)) {
|
||||
$connections = [];
|
||||
$hosts = explode(',', $hostsString);
|
||||
|
||||
foreach ($hosts as $hostEntry) {
|
||||
$hostEntry = trim($hostEntry);
|
||||
if (empty($hostEntry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts = explode(':', $hostEntry);
|
||||
$connections[] = [
|
||||
'host' => $parts[0],
|
||||
'port' => (int) ($parts[1] ?? self::DEFAULT_PORT),
|
||||
];
|
||||
}
|
||||
|
||||
if (! empty($connections)) {
|
||||
return [
|
||||
'connections' => $connections,
|
||||
'retries' => $this->config['retries'] ?? count($connections),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Single host configuration
|
||||
return [
|
||||
'host' => $this->config['host'],
|
||||
'port' => $this->config['port'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and validate configuration.
|
||||
*/
|
||||
@@ -94,6 +136,8 @@ class ManticoreSearch
|
||||
return [
|
||||
'host' => $config['host'] ?? self::DEFAULT_HOST,
|
||||
'port' => $config['port'] ?? self::DEFAULT_PORT,
|
||||
'hosts' => $config['hosts'] ?? '',
|
||||
'retries' => $config['retries'] ?? 2,
|
||||
'indexes' => $config['indexes'] ?? [
|
||||
'releases' => self::INDEX_RELEASES,
|
||||
'predb' => self::INDEX_PREDB,
|
||||
@@ -103,6 +147,17 @@ class ManticoreSearch
|
||||
'cache_minutes' => $config['cache_minutes'] ?? self::DEFAULT_CACHE_MINUTES,
|
||||
'max_matches' => $config['max_matches'] ?? self::DEFAULT_MAX_MATCHES,
|
||||
'batch_size' => $config['batch_size'] ?? self::DEFAULT_BATCH_SIZE,
|
||||
'autocomplete' => $config['autocomplete'] ?? [
|
||||
'enabled' => true,
|
||||
'min_length' => 2,
|
||||
'max_results' => 10,
|
||||
'fuzziness' => 1,
|
||||
'cache_minutes' => 10,
|
||||
],
|
||||
'suggest' => $config['suggest'] ?? [
|
||||
'enabled' => true,
|
||||
'max_edits' => 4,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -923,4 +978,492 @@ class ManticoreSearch
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get autocomplete suggestions for a search query.
|
||||
* Searches the releases index and returns matching searchnames.
|
||||
*
|
||||
* @param string $query The partial search query
|
||||
* @param string|null $index Index to search (defaults to releases index)
|
||||
* @return array<array{suggest: string, distance: int, docs: int}>
|
||||
*/
|
||||
public function autocomplete(string $query, ?string $index = null): array
|
||||
{
|
||||
$autocompleteConfig = $this->config['autocomplete'];
|
||||
|
||||
if (! $autocompleteConfig['enabled']) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$query = trim($query);
|
||||
if (strlen($query) < $autocompleteConfig['min_length']) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$index = $index ?? $this->getReleasesIndex();
|
||||
$cacheKey = 'manticore:autocomplete:'.md5($index.$query);
|
||||
|
||||
$cached = Cache::get($cacheKey);
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$suggestions = [];
|
||||
|
||||
try {
|
||||
// Search releases index for matching searchnames
|
||||
$escapedQuery = self::escapeString($query);
|
||||
if (empty($escapedQuery)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Use relaxed search on searchname field
|
||||
$searchExpr = '@@relaxed @searchname '.$escapedQuery;
|
||||
|
||||
$search = (new Search($this->manticoreSearch))
|
||||
->setTable($index)
|
||||
->search($searchExpr)
|
||||
->limit($autocompleteConfig['max_results'] * 3) // Get more to dedupe
|
||||
->stripBadUtf8(true);
|
||||
|
||||
$results = $search->get();
|
||||
|
||||
$seen = [];
|
||||
foreach ($results as $doc) {
|
||||
$data = $doc->getData();
|
||||
$searchname = $data['searchname'] ?? '';
|
||||
|
||||
if (empty($searchname)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create a clean suggestion from the searchname
|
||||
$suggestion = $this->extractSuggestion($searchname, $query);
|
||||
|
||||
if (! empty($suggestion) && ! isset($seen[strtolower($suggestion)])) {
|
||||
$seen[strtolower($suggestion)] = true;
|
||||
$suggestions[] = [
|
||||
'suggest' => $suggestion,
|
||||
'distance' => 0,
|
||||
'docs' => 1,
|
||||
];
|
||||
}
|
||||
|
||||
if (count($suggestions) >= $autocompleteConfig['max_results']) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
if (config('app.debug')) {
|
||||
Log::warning('ManticoreSearch autocomplete error: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($suggestions)) {
|
||||
Cache::put($cacheKey, $suggestions, now()->addMinutes((int) $autocompleteConfig['cache_minutes']));
|
||||
}
|
||||
|
||||
return $suggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a clean suggestion from a searchname.
|
||||
*
|
||||
* @param string $searchname The full searchname
|
||||
* @param string $query The user's query
|
||||
* @return string|null The extracted suggestion
|
||||
*/
|
||||
private function extractSuggestion(string $searchname, string $query): ?string
|
||||
{
|
||||
// Clean up the searchname - remove file extensions, quality tags at the end
|
||||
$clean = preg_replace('/\.(mkv|avi|mp4|wmv|nfo|nzb|par2|rar|zip|r\d+)$/i', '', $searchname);
|
||||
|
||||
// Replace dots and underscores with spaces for readability
|
||||
$clean = str_replace(['.', '_'], ' ', $clean);
|
||||
|
||||
// Remove multiple spaces
|
||||
$clean = preg_replace('/\s+/', ' ', $clean);
|
||||
$clean = trim($clean);
|
||||
|
||||
if (empty($clean)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If the clean name is reasonable length, use it
|
||||
if (strlen($clean) <= 80) {
|
||||
return $clean;
|
||||
}
|
||||
|
||||
// For very long names, try to extract the relevant part
|
||||
// Find where the query matches and extract context around it
|
||||
$pos = stripos($clean, $query);
|
||||
if ($pos !== false) {
|
||||
// Get up to 80 chars starting from the match position, or from beginning if match is early
|
||||
$start = max(0, $pos - 10);
|
||||
$extracted = substr($clean, $start, 80);
|
||||
|
||||
// Clean up - don't cut mid-word
|
||||
if ($start > 0) {
|
||||
$extracted = preg_replace('/^\S*\s/', '', $extracted);
|
||||
}
|
||||
$extracted = preg_replace('/\s\S*$/', '', $extracted);
|
||||
|
||||
return trim($extracted);
|
||||
}
|
||||
|
||||
// Fallback: just truncate
|
||||
return substr($clean, 0, 80);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get spell correction suggestions ("Did you mean?").
|
||||
* Uses CALL SUGGEST or falls back to searching releases for similar terms.
|
||||
*
|
||||
* @param string $query The search query to check
|
||||
* @param string|null $index Index to use for suggestions
|
||||
* @return array<array{suggest: string, distance: int, docs: int}>
|
||||
*/
|
||||
public function suggest(string $query, ?string $index = null): array
|
||||
{
|
||||
$suggestConfig = $this->config['suggest'];
|
||||
|
||||
if (! $suggestConfig['enabled']) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$query = trim($query);
|
||||
if (empty($query)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$index = $index ?? $this->getReleasesIndex();
|
||||
$cacheKey = 'manticore:suggest:'.md5($index.$query);
|
||||
|
||||
$cached = Cache::get($cacheKey);
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$suggestions = [];
|
||||
|
||||
try {
|
||||
// Try native CALL SUGGEST first
|
||||
$result = $this->manticoreSearch->suggest([
|
||||
'table' => $index,
|
||||
'body' => [
|
||||
'query' => $query,
|
||||
'options' => [
|
||||
'limit' => 5,
|
||||
'max_edits' => $suggestConfig['max_edits'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
if (! empty($result) && is_array($result)) {
|
||||
foreach ($result as $item) {
|
||||
if (isset($item['suggest']) && $item['suggest'] !== $query) {
|
||||
$suggestions[] = [
|
||||
'suggest' => $item['suggest'],
|
||||
'distance' => $item['distance'] ?? 0,
|
||||
'docs' => $item['docs'] ?? 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
if (config('app.debug')) {
|
||||
Log::debug('ManticoreSearch native suggest failed: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// If native suggest didn't return results, try a fuzzy search fallback
|
||||
if (empty($suggestions)) {
|
||||
$suggestions = $this->suggestFallback($query, $index);
|
||||
}
|
||||
|
||||
if (! empty($suggestions)) {
|
||||
Cache::put($cacheKey, $suggestions, now()->addMinutes((int) $this->cacheMinutes));
|
||||
}
|
||||
|
||||
return $suggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback suggest using similar searchname matches.
|
||||
*
|
||||
* @param string $query The search query
|
||||
* @param string $index Index to search
|
||||
* @return array<array{suggest: string, distance: int, docs: int}>
|
||||
*/
|
||||
private function suggestFallback(string $query, string $index): array
|
||||
{
|
||||
try {
|
||||
// Try to find releases with similar searchnames
|
||||
// This helps when users misspell common terms
|
||||
$escapedQuery = self::escapeString($query);
|
||||
if (empty($escapedQuery)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Use relaxed search to find partial matches
|
||||
$searchExpr = '@@relaxed @searchname '.$escapedQuery;
|
||||
|
||||
$search = (new Search($this->manticoreSearch))
|
||||
->setTable($index)
|
||||
->search($searchExpr)
|
||||
->limit(20)
|
||||
->stripBadUtf8(true);
|
||||
|
||||
$results = $search->get();
|
||||
|
||||
// Extract common terms from the results that differ from the query
|
||||
$termCounts = [];
|
||||
foreach ($results as $doc) {
|
||||
$data = $doc->getData();
|
||||
$searchname = $data['searchname'] ?? '';
|
||||
|
||||
// Extract words from searchname
|
||||
$words = preg_split('/[\s\.\-\_]+/', strtolower($searchname));
|
||||
foreach ($words as $word) {
|
||||
if (strlen($word) >= 3 && $word !== strtolower($query)) {
|
||||
// Check if word is similar to query (within edit distance)
|
||||
$distance = levenshtein(strtolower($query), $word);
|
||||
if ($distance > 0 && $distance <= 3) {
|
||||
if (! isset($termCounts[$word])) {
|
||||
$termCounts[$word] = ['count' => 0, 'distance' => $distance];
|
||||
}
|
||||
$termCounts[$word]['count']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by count (most common first)
|
||||
uasort($termCounts, fn($a, $b) => $b['count'] - $a['count']);
|
||||
|
||||
$suggestions = [];
|
||||
foreach (array_slice($termCounts, 0, 5, true) as $term => $data) {
|
||||
$suggestions[] = [
|
||||
'suggest' => $term,
|
||||
'distance' => $data['distance'],
|
||||
'docs' => $data['count'],
|
||||
];
|
||||
}
|
||||
|
||||
return $suggestions;
|
||||
} catch (\Throwable $e) {
|
||||
if (config('app.debug')) {
|
||||
Log::warning('ManticoreSearch suggest fallback error: '.$e->getMessage());
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search with result highlighting.
|
||||
*
|
||||
* @param string $index Index to search
|
||||
* @param string $query Search query
|
||||
* @param array<string> $highlightFields Fields to highlight
|
||||
* @param int $limit Maximum results
|
||||
* @return array{results: array, total: int}
|
||||
*/
|
||||
public function searchWithHighlight(
|
||||
string $index,
|
||||
string $query,
|
||||
array $highlightFields = ['searchname', 'name'],
|
||||
int $limit = 100
|
||||
): array {
|
||||
if (empty($query)) {
|
||||
return ['results' => [], 'total' => 0];
|
||||
}
|
||||
|
||||
$cacheKey = 'manticore:highlight:'.md5($index.$query.implode(',', $highlightFields).$limit);
|
||||
$cached = Cache::get($cacheKey);
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
try {
|
||||
$escapedQuery = self::escapeString($query);
|
||||
if (empty($escapedQuery)) {
|
||||
return ['results' => [], 'total' => 0];
|
||||
}
|
||||
|
||||
$search = (new Search($this->manticoreSearch))
|
||||
->setTable($index)
|
||||
->search($escapedQuery)
|
||||
->highlight($highlightFields, [
|
||||
'pre_tags' => '<mark class="search-highlight bg-yellow-200 dark:bg-yellow-700 px-0.5 rounded">',
|
||||
'post_tags' => '</mark>',
|
||||
'limit' => 256,
|
||||
'no_match_size' => 0,
|
||||
])
|
||||
->limit($limit)
|
||||
->maxMatches($this->config['max_matches'])
|
||||
->stripBadUtf8(true);
|
||||
|
||||
$resultSet = $search->get();
|
||||
|
||||
$results = [];
|
||||
foreach ($resultSet as $doc) {
|
||||
$results[] = [
|
||||
'id' => $doc->getId(),
|
||||
'data' => $doc->getData(),
|
||||
'highlight' => $doc->getHighlight() ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
$response = [
|
||||
'results' => $results,
|
||||
'total' => $resultSet->getTotal() ?? count($results),
|
||||
];
|
||||
|
||||
Cache::put($cacheKey, $response, now()->addMinutes($this->cacheMinutes));
|
||||
|
||||
return $response;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('ManticoreSearch searchWithHighlight error: '.$e->getMessage(), [
|
||||
'index' => $index,
|
||||
'query' => $query,
|
||||
]);
|
||||
|
||||
return ['results' => [], 'total' => 0];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Advanced search using structured BoolQuery with filters.
|
||||
*
|
||||
* @param string $index Index to search
|
||||
* @param array{
|
||||
* search?: string,
|
||||
* searchname?: string,
|
||||
* name?: string,
|
||||
* fromname?: string,
|
||||
* filename?: string,
|
||||
* categories?: array<int>,
|
||||
* exclude_categories?: array<int>,
|
||||
* min_date?: string,
|
||||
* max_date?: string,
|
||||
* sort_field?: string,
|
||||
* sort_dir?: string,
|
||||
* limit?: int,
|
||||
* offset?: int
|
||||
* } $params Search parameters
|
||||
* @return array{ids: array<int>, total: int}
|
||||
*/
|
||||
public function advancedSearch(string $index, array $params): array
|
||||
{
|
||||
$cacheKey = 'manticore:advsearch:'.md5($index.serialize($params));
|
||||
$cached = Cache::get($cacheKey);
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
try {
|
||||
$query = new BoolQuery;
|
||||
$hasQuery = false;
|
||||
|
||||
// Full-text search across multiple fields
|
||||
if (! empty($params['search'])) {
|
||||
$escaped = self::escapeString($params['search']);
|
||||
if (! empty($escaped)) {
|
||||
$query->must(new MatchQuery($escaped, 'searchname,name,filename'));
|
||||
$hasQuery = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Field-specific searches
|
||||
$fieldMappings = [
|
||||
'searchname' => 'searchname',
|
||||
'name' => 'name',
|
||||
'fromname' => 'fromname',
|
||||
'filename' => 'filename',
|
||||
];
|
||||
|
||||
foreach ($fieldMappings as $paramKey => $field) {
|
||||
if (! empty($params[$paramKey])) {
|
||||
$escaped = self::escapeString($params[$paramKey]);
|
||||
if (! empty($escaped)) {
|
||||
$query->must(new MatchQuery($escaped, $field));
|
||||
$hasQuery = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Category filter
|
||||
if (! empty($params['categories']) && is_array($params['categories'])) {
|
||||
$query->must(new In('categories_id', array_map('intval', $params['categories'])));
|
||||
$hasQuery = true;
|
||||
}
|
||||
|
||||
// Exclude categories
|
||||
if (! empty($params['exclude_categories']) && is_array($params['exclude_categories'])) {
|
||||
$query->mustNot(new In('categories_id', array_map('intval', $params['exclude_categories'])));
|
||||
}
|
||||
|
||||
if (! $hasQuery) {
|
||||
return ['ids' => [], 'total' => 0];
|
||||
}
|
||||
|
||||
$limit = $params['limit'] ?? $this->config['max_matches'];
|
||||
$offset = $params['offset'] ?? 0;
|
||||
|
||||
$search = (new Search($this->manticoreSearch))
|
||||
->setTable($index)
|
||||
->search($query)
|
||||
->limit($limit)
|
||||
->offset($offset)
|
||||
->maxMatches($this->config['max_matches'])
|
||||
->stripBadUtf8(true);
|
||||
|
||||
// Sorting
|
||||
$sortField = $params['sort_field'] ?? 'id';
|
||||
$sortDir = strtolower($params['sort_dir'] ?? 'desc');
|
||||
if (in_array($sortDir, ['asc', 'desc'])) {
|
||||
$search->sort($sortField, $sortDir);
|
||||
}
|
||||
|
||||
$resultSet = $search->get();
|
||||
|
||||
$ids = [];
|
||||
foreach ($resultSet as $doc) {
|
||||
$ids[] = $doc->getId();
|
||||
}
|
||||
|
||||
$response = [
|
||||
'ids' => $ids,
|
||||
'total' => $resultSet->getTotal() ?? count($ids),
|
||||
];
|
||||
|
||||
Cache::put($cacheKey, $response, now()->addMinutes($this->cacheMinutes));
|
||||
|
||||
return $response;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('ManticoreSearch advancedSearch error: '.$e->getMessage(), [
|
||||
'index' => $index,
|
||||
'params' => $params,
|
||||
]);
|
||||
|
||||
return ['ids' => [], 'total' => 0];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if autocomplete is enabled.
|
||||
*/
|
||||
public function isAutocompleteEnabled(): bool
|
||||
{
|
||||
return $this->config['autocomplete']['enabled'] ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if suggest is enabled.
|
||||
*/
|
||||
public function isSuggestEnabled(): bool
|
||||
{
|
||||
return $this->config['suggest']['enabled'] ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,20 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\UsenetGroup;
|
||||
use Blacklight\ManticoreSearch;
|
||||
use Blacklight\Releases;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SearchController extends BasePageController
|
||||
{
|
||||
private ManticoreSearch $manticore;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->manticore = new ManticoreSearch;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
@@ -133,6 +142,22 @@ class SearchController extends BasePageController
|
||||
$searchVars['selectedsizefrom'] = $searchVars['searchadvsizefrom'];
|
||||
$searchVars['selectedsizeto'] = $searchVars['searchadvsizeto'];
|
||||
|
||||
// Get spell correction suggestions if we have a search query but few/no results
|
||||
$spellSuggestion = null;
|
||||
$searchQuery = $search ?: ($searchVars['searchadvr'] ?? '');
|
||||
if (! empty($searchQuery) && $this->manticore->isSuggestEnabled()) {
|
||||
// Get suggestions from ManticoreSearch
|
||||
$suggestions = $this->manticore->suggest($searchQuery);
|
||||
if (! empty($suggestions)) {
|
||||
// Sort by doc count descending to get best suggestion
|
||||
usort($suggestions, fn ($a, $b) => $b['docs'] - $a['docs']);
|
||||
// Only show suggestion if it's different from the query
|
||||
if ($suggestions[0]['suggest'] !== $searchQuery) {
|
||||
$spellSuggestion = $suggestions[0]['suggest'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($searchType !== 'basic' && $request->missing('id') && $request->missing('subject') && $request->anyFilled(['searchadvr', 'searchadvsubject', 'searchadvfilename', 'searchadvposter', 'minage', 'maxage', 'group', 'minsize', 'maxsize', 'search'])) {
|
||||
$orderByString = '';
|
||||
foreach ($searchVars as $searchVarKey => $searchVar) {
|
||||
@@ -189,6 +214,10 @@ class SearchController extends BasePageController
|
||||
'meta_title' => 'Search Nzbs',
|
||||
'meta_keywords' => 'search,nzb,description,details',
|
||||
'meta_description' => 'Search for Nzbs',
|
||||
// ManticoreSearch enhanced features
|
||||
'spellSuggestion' => $spellSuggestion,
|
||||
'autocompleteEnabled' => $this->manticore->isAutocompleteEnabled(),
|
||||
'suggestEnabled' => $this->manticore->isSuggestEnabled(),
|
||||
]);
|
||||
|
||||
return view('search.index', $this->viewData);
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Blacklight\ManticoreSearch;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
class SearchSuggestController extends Controller
|
||||
{
|
||||
private ManticoreSearch $manticore;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->manticore = new ManticoreSearch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get autocomplete suggestions for search input.
|
||||
*/
|
||||
public function autocomplete(Request $request): JsonResponse
|
||||
{
|
||||
$query = $request->input('q', '');
|
||||
// Use the index from ManticoreSearch config, or allow override via request
|
||||
$index = $request->input('index') ?? $this->manticore->getReleasesIndex();
|
||||
|
||||
// Rate limiting: 60 requests per minute per IP
|
||||
$key = 'autocomplete:'.$request->ip();
|
||||
if (RateLimiter::tooManyAttempts($key, 60)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'Too many requests',
|
||||
], 429);
|
||||
}
|
||||
RateLimiter::hit($key, 60);
|
||||
|
||||
if (! $this->manticore->isAutocompleteEnabled()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'suggestions' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
if (strlen($query) < 2) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'suggestions' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$suggestions = $this->manticore->autocomplete($query, $index);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'query' => $query,
|
||||
'suggestions' => array_map(fn ($s) => $s['suggest'], $suggestions),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'query' => $query,
|
||||
'suggestions' => [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get spell correction suggestions ("Did you mean?").
|
||||
*/
|
||||
public function suggest(Request $request): JsonResponse
|
||||
{
|
||||
$query = $request->input('q', '');
|
||||
$index = $request->input('index', 'releases_rt');
|
||||
|
||||
// Rate limiting: 30 requests per minute per IP
|
||||
$key = 'suggest:'.$request->ip();
|
||||
if (RateLimiter::tooManyAttempts($key, 30)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'Too many requests',
|
||||
], 429);
|
||||
}
|
||||
RateLimiter::hit($key, 60);
|
||||
|
||||
if (! $this->manticore->isSuggestEnabled()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'suggestions' => [],
|
||||
'error' => 'Suggest is disabled',
|
||||
]);
|
||||
}
|
||||
|
||||
if (empty($query)) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'suggestions' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
$suggestions = $this->manticore->suggest($query, $index);
|
||||
|
||||
// Get the best suggestion (highest doc count)
|
||||
$bestSuggestion = null;
|
||||
if (! empty($suggestions)) {
|
||||
usort($suggestions, fn ($a, $b) => $b['docs'] - $a['docs']);
|
||||
$bestSuggestion = $suggestions[0]['suggest'] ?? null;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'query' => $query,
|
||||
'suggestions' => array_map(fn ($s) => $s['suggest'], $suggestions),
|
||||
'best' => $bestSuggestion,
|
||||
'details' => $suggestions,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined endpoint for search assistance (autocomplete + suggest).
|
||||
*/
|
||||
public function searchAssist(Request $request): JsonResponse
|
||||
{
|
||||
$query = $request->input('q', '');
|
||||
$index = $request->input('index', 'releases_rt');
|
||||
|
||||
// Rate limiting
|
||||
$key = 'searchassist:'.$request->ip();
|
||||
if (RateLimiter::tooManyAttempts($key, 60)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'Too many requests',
|
||||
], 429);
|
||||
}
|
||||
RateLimiter::hit($key, 60);
|
||||
|
||||
$response = [
|
||||
'success' => true,
|
||||
'query' => $query,
|
||||
'autocomplete' => [],
|
||||
'suggest' => null,
|
||||
];
|
||||
|
||||
// Get autocomplete suggestions
|
||||
if ($this->manticore->isAutocompleteEnabled() && strlen($query) >= 2) {
|
||||
$autocomplete = $this->manticore->autocomplete($query, $index);
|
||||
$response['autocomplete'] = array_map(fn ($s) => $s['suggest'], $autocomplete);
|
||||
}
|
||||
|
||||
// Get spell suggestion if query is complete (user stopped typing)
|
||||
if ($this->manticore->isSuggestEnabled() && ! empty($query)) {
|
||||
$suggestions = $this->manticore->suggest($query, $index);
|
||||
if (! empty($suggestions)) {
|
||||
usort($suggestions, fn ($a, $b) => $b['docs'] - $a['docs']);
|
||||
$response['suggest'] = $suggestions[0]['suggest'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json($response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,24 @@ return [
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configure the connection to your ManticoreSearch server.
|
||||
| For high availability, you can configure multiple hosts.
|
||||
|
|
||||
*/
|
||||
'host' => env('MANTICORESEARCH_HOST', '127.0.0.1'),
|
||||
'port' => env('MANTICORESEARCH_PORT', 9308),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Multiple Connections (High Availability)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configure multiple ManticoreSearch nodes for failover.
|
||||
| Set MANTICORESEARCH_HOSTS as comma-separated list: "host1:9308,host2:9308"
|
||||
|
|
||||
*/
|
||||
'hosts' => env('MANTICORESEARCH_HOSTS', ''),
|
||||
'retries' => env('MANTICORESEARCH_RETRIES', 2),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Index Names
|
||||
@@ -36,6 +49,35 @@ return [
|
||||
'max_matches' => env('MANTICORESEARCH_MAX_MATCHES', 10000),
|
||||
'cache_minutes' => env('MANTICORESEARCH_CACHE_MINUTES', 5),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Autocomplete Settings
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configure autocomplete/suggest behavior.
|
||||
|
|
||||
*/
|
||||
'autocomplete' => [
|
||||
'enabled' => env('MANTICORESEARCH_AUTOCOMPLETE_ENABLED', true),
|
||||
'min_length' => (int) env('MANTICORESEARCH_AUTOCOMPLETE_MIN_LENGTH', 2),
|
||||
'max_results' => (int) env('MANTICORESEARCH_AUTOCOMPLETE_MAX_RESULTS', 10),
|
||||
'fuzziness' => (int) env('MANTICORESEARCH_AUTOCOMPLETE_FUZZINESS', 1),
|
||||
'cache_minutes' => (int) env('MANTICORESEARCH_AUTOCOMPLETE_CACHE', 10),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Suggest/Spell Correction Settings
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configure "Did you mean?" spell correction.
|
||||
|
|
||||
*/
|
||||
'suggest' => [
|
||||
'enabled' => env('MANTICORESEARCH_SUGGEST_ENABLED', true),
|
||||
'max_edits' => (int) env('MANTICORESEARCH_SUGGEST_MAX_EDITS', 4),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Batch Processing
|
||||
|
||||
@@ -355,3 +355,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Preview Modal CSP-Safe Styles */
|
||||
.preview-modal-hidden,
|
||||
.modal-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#previewModal,
|
||||
.modal-z-index {
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
#previewModal.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Modal content scroll area */
|
||||
.modal-content-scroll {
|
||||
max-height: calc(90vh - 80px);
|
||||
}
|
||||
|
||||
/* Search highlight styling */
|
||||
.search-highlight,
|
||||
mark {
|
||||
@apply bg-yellow-200 px-0.5 rounded;
|
||||
}
|
||||
|
||||
.dark .search-highlight,
|
||||
.dark mark {
|
||||
@apply bg-yellow-700;
|
||||
}
|
||||
|
||||
@@ -275,6 +275,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
initContentToggle();
|
||||
initContentDelete();
|
||||
initPasswordVisibilityToggles();
|
||||
initSearchAutocomplete();
|
||||
|
||||
// Initialize page-specific functionality from inline scripts
|
||||
initMyMovies();
|
||||
@@ -5185,3 +5186,218 @@ function initUserListScrollSync() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize search autocomplete functionality
|
||||
* Handles both the main search page and header search inputs
|
||||
*/
|
||||
function initSearchAutocomplete() {
|
||||
// Initialize header autocomplete (desktop and mobile)
|
||||
initAutocompleteInput('header-search-input', 'header-autocomplete-dropdown', 'header-search-form', 'header-autocomplete-item');
|
||||
initAutocompleteInput('mobile-search-input', 'mobile-autocomplete-dropdown', 'mobile-search-form-el', 'header-autocomplete-item');
|
||||
|
||||
// Initialize main search page autocomplete
|
||||
initAutocompleteInput('search', 'autocomplete-dropdown', 'searchForm', 'autocomplete-item');
|
||||
|
||||
// Initialize any dynamic autocomplete inputs (from Blade components)
|
||||
document.querySelectorAll('[data-autocomplete-input]').forEach(function(input) {
|
||||
const dropdownId = input.getAttribute('data-autocomplete-input');
|
||||
const formId = input.getAttribute('data-autocomplete-form');
|
||||
const inputId = input.id;
|
||||
if (inputId && dropdownId) {
|
||||
initAutocompleteInput(inputId, dropdownId, formId, 'autocomplete-suggestion');
|
||||
}
|
||||
});
|
||||
|
||||
// Apply CSP-safe styles to modals (replaces inline styles)
|
||||
initModalStyles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply CSP-safe styles to modals that previously had inline styles
|
||||
*/
|
||||
function initModalStyles() {
|
||||
// Preview modal
|
||||
const previewModal = document.getElementById('previewModal');
|
||||
if (previewModal) {
|
||||
previewModal.style.display = 'none';
|
||||
previewModal.style.zIndex = '9999';
|
||||
}
|
||||
|
||||
// NFO modal
|
||||
const nfoModal = document.getElementById('nfoModal');
|
||||
if (nfoModal) {
|
||||
nfoModal.style.display = 'none';
|
||||
}
|
||||
|
||||
// Image modal
|
||||
const imageModal = document.getElementById('imageModal');
|
||||
if (imageModal) {
|
||||
imageModal.style.display = 'none';
|
||||
}
|
||||
|
||||
// MediaInfo modal
|
||||
const mediainfoModal = document.getElementById('mediainfoModal');
|
||||
if (mediainfoModal) {
|
||||
mediainfoModal.style.display = 'none';
|
||||
mediainfoModal.style.zIndex = '9999';
|
||||
}
|
||||
|
||||
// Filelist modal
|
||||
const filelistModal = document.getElementById('filelistModal');
|
||||
if (filelistModal) {
|
||||
filelistModal.style.display = 'none';
|
||||
filelistModal.style.zIndex = '9999';
|
||||
}
|
||||
|
||||
// MediaInfo content scroll
|
||||
const mediainfoContent = document.getElementById('mediainfoContent');
|
||||
if (mediainfoContent) {
|
||||
mediainfoContent.style.maxHeight = 'calc(90vh - 80px)';
|
||||
}
|
||||
|
||||
// Filelist content scroll
|
||||
const filelistContent = document.getElementById('filelistContent');
|
||||
if (filelistContent) {
|
||||
filelistContent.style.maxHeight = 'calc(90vh - 80px)';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize autocomplete for a specific input element
|
||||
* @param {string} inputId - ID of the search input
|
||||
* @param {string} dropdownId - ID of the dropdown container
|
||||
* @param {string} formId - ID of the form to submit
|
||||
* @param {string} itemClass - CSS class for dropdown items
|
||||
*/
|
||||
function initAutocompleteInput(inputId, dropdownId, formId, itemClass) {
|
||||
const searchInput = document.getElementById(inputId);
|
||||
const dropdown = document.getElementById(dropdownId);
|
||||
const form = document.getElementById(formId);
|
||||
|
||||
if (!searchInput || !dropdown) return;
|
||||
|
||||
let debounceTimer;
|
||||
let currentIndex = -1;
|
||||
let suggestions = [];
|
||||
|
||||
// Input handler with debounce
|
||||
searchInput.addEventListener('input', function() {
|
||||
clearTimeout(debounceTimer);
|
||||
const query = this.value.trim();
|
||||
|
||||
if (query.length < 2) {
|
||||
hideDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
debounceTimer = setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/search/autocomplete?q=${encodeURIComponent(query)}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.suggestions && data.suggestions.length > 0) {
|
||||
suggestions = data.suggestions;
|
||||
renderDropdown(suggestions, query);
|
||||
} else {
|
||||
hideDropdown();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Autocomplete error:', error);
|
||||
hideDropdown();
|
||||
}
|
||||
}, 200);
|
||||
});
|
||||
|
||||
// Keyboard navigation
|
||||
searchInput.addEventListener('keydown', function(e) {
|
||||
if (!dropdown.classList.contains('hidden')) {
|
||||
const items = dropdown.querySelectorAll('.' + itemClass);
|
||||
|
||||
switch(e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
currentIndex = Math.min(currentIndex + 1, items.length - 1);
|
||||
updateSelection(items);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
currentIndex = Math.max(currentIndex - 1, 0);
|
||||
updateSelection(items);
|
||||
break;
|
||||
case 'Enter':
|
||||
if (currentIndex >= 0 && items[currentIndex]) {
|
||||
e.preventDefault();
|
||||
searchInput.value = suggestions[currentIndex];
|
||||
hideDropdown();
|
||||
if (form) form.submit();
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
hideDropdown();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Click outside to close
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!searchInput.contains(e.target) && !dropdown.contains(e.target)) {
|
||||
hideDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
function renderDropdown(items, query) {
|
||||
currentIndex = -1;
|
||||
const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(`(${escapeRegex(query)})`, 'gi');
|
||||
|
||||
const maxItems = itemClass === 'header-autocomplete-item' ? 8 : 10;
|
||||
const sizeClass = itemClass === 'header-autocomplete-item' ? 'px-3 py-2 text-sm' : 'px-4 py-2';
|
||||
const iconSizeClass = itemClass === 'header-autocomplete-item' ? 'text-xs' : '';
|
||||
|
||||
dropdown.innerHTML = items.slice(0, maxItems).map((item, index) => {
|
||||
const highlighted = item.replace(regex, '<mark class="bg-yellow-200 dark:bg-yellow-700 px-0.5 rounded">$1</mark>');
|
||||
return `
|
||||
<div class="${itemClass} ${sizeClass} cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
data-index="${index}">
|
||||
<i class="fas fa-search text-gray-400 mr-2 ${iconSizeClass}"></i>
|
||||
${highlighted}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Add click handlers
|
||||
dropdown.querySelectorAll('.' + itemClass).forEach((item, index) => {
|
||||
item.addEventListener('click', function() {
|
||||
searchInput.value = suggestions[index];
|
||||
hideDropdown();
|
||||
if (form) form.submit();
|
||||
});
|
||||
item.addEventListener('mouseenter', function() {
|
||||
currentIndex = index;
|
||||
updateSelection(dropdown.querySelectorAll('.' + itemClass));
|
||||
});
|
||||
});
|
||||
|
||||
dropdown.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideDropdown() {
|
||||
dropdown.classList.add('hidden');
|
||||
dropdown.innerHTML = '';
|
||||
suggestions = [];
|
||||
currentIndex = -1;
|
||||
}
|
||||
|
||||
function updateSelection(items) {
|
||||
items.forEach((item, index) => {
|
||||
if (index === currentIndex) {
|
||||
item.classList.add('bg-blue-100', 'dark:bg-blue-900');
|
||||
} else {
|
||||
item.classList.remove('bg-blue-100', 'dark:bg-blue-900');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -277,7 +277,7 @@
|
||||
@endif
|
||||
|
||||
<!-- Preview/Sample Image Modal -->
|
||||
<div id="previewModal" class="hidden fixed inset-0 bg-black bg-opacity-75 items-center justify-center p-4" style="display: none; z-index: 9999 !important;">
|
||||
<div id="previewModal" class="hidden fixed inset-0 bg-black bg-opacity-75 items-center justify-center p-4 preview-modal-hidden">
|
||||
<div class="relative max-w-4xl w-full">
|
||||
<button type="button" data-close-preview-modal class="absolute top-4 right-4 text-white hover:text-gray-300 text-3xl font-bold z-10">
|
||||
<i class="fas fa-times"></i>
|
||||
@@ -293,7 +293,7 @@
|
||||
</div>
|
||||
|
||||
<!-- MediaInfo Modal -->
|
||||
<div id="mediainfoModal" class="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4" style="display: none; z-index: 9999 !important;">
|
||||
<div id="mediainfoModal" class="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 modal-hidden modal-z-index">
|
||||
<div class="relative max-w-4xl w-full bg-white dark:bg-gray-800 dark:bg-gray-800 rounded-lg shadow-2xl max-h-[90vh] overflow-hidden">
|
||||
<div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 dark:text-gray-200 flex items-center">
|
||||
@@ -303,7 +303,7 @@
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="mediainfoContent" class="p-6 overflow-y-auto" style="max-height: calc(90vh - 80px);">
|
||||
<div id="mediainfoContent" class="p-6 overflow-y-auto modal-content-scroll">
|
||||
<div class="text-center py-8">
|
||||
<i class="fas fa-spinner fa-spin text-3xl text-blue-600 dark:text-blue-400 dark:text-blue-400"></i>
|
||||
<p class="text-gray-600 dark:text-gray-400 dark:text-gray-400 mt-2">Loading media information...</p>
|
||||
@@ -313,7 +313,7 @@
|
||||
</div>
|
||||
|
||||
<!-- File List Modal -->
|
||||
<div id="filelistModal" class="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4" style="display: none; z-index: 9999 !important;">
|
||||
<div id="filelistModal" class="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 modal-hidden modal-z-index">
|
||||
<div class="relative max-w-4xl w-full bg-white dark:bg-gray-800 dark:bg-gray-800 rounded-lg shadow-2xl max-h-[90vh] overflow-hidden">
|
||||
<div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 dark:text-gray-200 flex items-center">
|
||||
@@ -323,7 +323,7 @@
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="filelistContent" class="p-6 overflow-y-auto" style="max-height: calc(90vh - 80px);">
|
||||
<div id="filelistContent" class="p-6 overflow-y-auto modal-content-scroll">
|
||||
<div class="text-center py-8">
|
||||
<i class="fas fa-spinner fa-spin text-3xl text-green-600 dark:text-green-400"></i>
|
||||
<p class="text-gray-600 dark:text-gray-400 dark:text-gray-400 mt-2">Loading file list...</p>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
@props([
|
||||
'placeholder' => 'Search releases...',
|
||||
'name' => 'search',
|
||||
'value' => '',
|
||||
'categorySelect' => false,
|
||||
'categories' => [],
|
||||
'selectedCategory' => '-1',
|
||||
'submitButton' => true,
|
||||
'formId' => 'search-form-' . uniqid(),
|
||||
'inputId' => 'search-input-' . uniqid(),
|
||||
'size' => 'normal', // 'small', 'normal', 'large'
|
||||
'showSuggestion' => false,
|
||||
'suggestion' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$sizeClasses = match($size) {
|
||||
'small' => 'text-sm px-3 py-1.5',
|
||||
'large' => 'text-lg px-4 py-3',
|
||||
default => 'text-base px-4 py-2',
|
||||
};
|
||||
@endphp
|
||||
|
||||
<form method="GET" action="{{ route('search') }}" class="relative" id="{{ $formId }}" data-autocomplete-form>
|
||||
<div class="flex items-center">
|
||||
@if($categorySelect && !empty($categories))
|
||||
<select name="t"
|
||||
class="bg-gray-700 text-white {{ $sizeClasses }} rounded-l border-r border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="-1">All</option>
|
||||
@foreach($categories as $category)
|
||||
@php
|
||||
$catId = is_object($category) ? $category->id : ($category['id'] ?? '');
|
||||
$catTitle = is_object($category) ? $category->title : ($category['title'] ?? '');
|
||||
$subcats = is_object($category) ? ($category->categories ?? []) : ($category['categories'] ?? []);
|
||||
@endphp
|
||||
<option value="{{ $catId }}" {{ $selectedCategory == $catId ? 'selected' : '' }} class="font-semibold">
|
||||
{{ $catTitle }}
|
||||
</option>
|
||||
@foreach($subcats as $subcat)
|
||||
@php
|
||||
$subcatId = is_object($subcat) ? $subcat->id : ($subcat['id'] ?? '');
|
||||
$subcatTitle = is_object($subcat) ? $subcat->title : ($subcat['title'] ?? '');
|
||||
@endphp
|
||||
<option value="{{ $subcatId }}" {{ $selectedCategory == $subcatId ? 'selected' : '' }}>
|
||||
{{ $subcatTitle }}
|
||||
</option>
|
||||
@endforeach
|
||||
@endforeach
|
||||
</select>
|
||||
@endif
|
||||
|
||||
<div class="relative flex-1">
|
||||
<input type="search"
|
||||
name="{{ $name }}"
|
||||
id="{{ $inputId }}"
|
||||
value="{{ $value }}"
|
||||
placeholder="{{ $placeholder }}"
|
||||
autocomplete="off"
|
||||
data-autocomplete-input="{{ $inputId }}-dropdown"
|
||||
data-autocomplete-form="{{ $formId }}"
|
||||
class="w-full bg-white dark:bg-gray-700 text-gray-900 dark:text-white {{ $sizeClasses }}
|
||||
{{ $categorySelect ? '' : 'rounded-l' }}
|
||||
{{ $submitButton ? '' : 'rounded-r' }}
|
||||
border border-gray-300 dark:border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
|
||||
<!-- Autocomplete Dropdown -->
|
||||
<div id="{{ $inputId }}-dropdown"
|
||||
class="hidden absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($submitButton)
|
||||
<button type="submit"
|
||||
class="bg-blue-600 dark:bg-blue-700 hover:bg-blue-700 dark:hover:bg-blue-800 text-white {{ $sizeClasses }} rounded-r transition font-medium">
|
||||
<i class="fas fa-search"></i>
|
||||
<span class="sr-only">Search</span>
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if($showSuggestion && !empty($suggestion))
|
||||
<div class="mt-2 text-sm">
|
||||
<span class="text-gray-600 dark:text-gray-400">Did you mean: </span>
|
||||
<a href="{{ route('search', ['search' => $suggestion]) }}"
|
||||
class="text-blue-600 dark:text-blue-400 hover:underline font-medium">
|
||||
{{ $suggestion }}
|
||||
</a>
|
||||
<span class="text-gray-500 dark:text-gray-500">?</span>
|
||||
</div>
|
||||
@endif
|
||||
</form>
|
||||
@@ -472,14 +472,14 @@
|
||||
$anidbDescription = $anidbData['description'] ?? ($anidb->description ?? null);
|
||||
$anidbCategories = $anidbData['categories'] ?? ($anidb->categories ?? null);
|
||||
$anidbCreators = $anidbData['creators'] ?? ($anidb->creators ?? null);
|
||||
|
||||
|
||||
// Get country name from country code
|
||||
$countryName = null;
|
||||
if (!empty($anidbCountry)) {
|
||||
$country = \App\Models\Country::where('iso_3166_2', $anidbCountry)->first();
|
||||
$countryName = $country->name ?? $anidbCountry;
|
||||
}
|
||||
|
||||
|
||||
// Format status
|
||||
$statusLabels = [
|
||||
'FINISHED' => 'Finished',
|
||||
@@ -489,7 +489,7 @@
|
||||
'HIATUS' => 'Hiatus',
|
||||
];
|
||||
$anidbStatusLabel = $statusLabels[$anidbStatus] ?? $anidbStatus;
|
||||
|
||||
|
||||
// Format source
|
||||
$sourceLabels = [
|
||||
'ORIGINAL' => 'Original',
|
||||
@@ -509,7 +509,7 @@
|
||||
@endphp
|
||||
<div class="bg-gradient-to-r from-pink-50 to-purple-50 dark:from-pink-900 dark:to-purple-900 rounded-lg p-6 border border-pink-100 dark:border-pink-800">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4 flex items-center">
|
||||
<i class="fas fa-dragon mr-2 text-pink-600 dark:text-pink-400"></i>
|
||||
<i class="fas fa-dragon mr-2 text-pink-600 dark:text-pink-400"></i>
|
||||
@if($anidbMediaType === 'MANGA')
|
||||
Manga Information
|
||||
@else
|
||||
@@ -591,8 +591,8 @@
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
<span class="inline-flex items-center">
|
||||
@if(!empty($country))
|
||||
<img src="{{ asset('assets/images/flags/' . strtolower($anidbCountry) . '.png') }}"
|
||||
alt="{{ $countryName }}"
|
||||
<img src="{{ asset('assets/images/flags/' . strtolower($anidbCountry) . '.png') }}"
|
||||
alt="{{ $countryName }}"
|
||||
class="w-4 h-3 mr-1"
|
||||
onerror="this.style.display='none'">
|
||||
@endif
|
||||
@@ -672,7 +672,7 @@
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
|
||||
<!-- External Links -->
|
||||
@php
|
||||
$anilistId = $anidbData['anilist_id'] ?? ($anidb->anilist_id ?? null);
|
||||
@@ -1117,7 +1117,7 @@
|
||||
@endsection
|
||||
|
||||
<!-- Image Modal -->
|
||||
<div id="imageModal" class="image-modal-backdrop hidden" style="display: none;">
|
||||
<div id="imageModal" class="image-modal-backdrop hidden modal-hidden">
|
||||
<div class="image-modal-container">
|
||||
<button type="button" class="image-modal-close" data-close-image-modal>
|
||||
<i class="fas fa-times"></i>
|
||||
|
||||
@@ -122,8 +122,8 @@
|
||||
<i class="fa fa-search text-lg"></i>
|
||||
</button>
|
||||
|
||||
<!-- Desktop Search Form -->
|
||||
<form method="GET" action="{{ route('search') }}" class="hidden lg:flex items-center">
|
||||
<!-- Desktop Search Form with Autocomplete -->
|
||||
<form method="GET" action="{{ route('search') }}" class="hidden lg:flex items-center relative" id="header-search-form">
|
||||
<select name="t" class="bg-gray-700 text-white text-sm rounded-l px-3 py-2 border-r border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="-1">All</option>
|
||||
@if(isset($parentcatlist))
|
||||
@@ -135,7 +135,18 @@
|
||||
@endforeach
|
||||
@endif
|
||||
</select>
|
||||
<input type="search" name="search" value="{{ $header_menu_search ?? '' }}" placeholder="Search..." class="bg-gray-700 text-white text-sm px-3 py-2 w-48 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<div class="relative">
|
||||
<input type="search"
|
||||
name="search"
|
||||
id="header-search-input"
|
||||
value="{{ $header_menu_search ?? '' }}"
|
||||
placeholder="Search..."
|
||||
autocomplete="off"
|
||||
class="bg-gray-700 text-white text-sm px-3 py-2 w-48 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<!-- Autocomplete dropdown for header -->
|
||||
<div id="header-autocomplete-dropdown" class="hidden absolute z-50 w-64 mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-y-auto right-0">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="bg-green-600 dark:bg-green-700 hover:bg-green-700 dark:hover:bg-green-800 text-white px-4 py-2 rounded-r transition">
|
||||
<i class="fa fa-search"></i>
|
||||
</button>
|
||||
@@ -143,7 +154,7 @@
|
||||
|
||||
<!-- Mobile Search Form (Hidden by default) -->
|
||||
<div id="mobile-search-form" class="lg:hidden hidden absolute top-16 left-0 right-0 bg-gray-800 dark:bg-gray-950 p-4 shadow-lg z-50">
|
||||
<form method="GET" action="{{ route('search') }}" class="space-y-3">
|
||||
<form method="GET" action="{{ route('search') }}" class="space-y-3" id="mobile-search-form-el">
|
||||
<select name="t" class="w-full bg-gray-700 text-white text-sm rounded px-3 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500 touch-target">
|
||||
<option value="-1">All Categories</option>
|
||||
@if(isset($parentcatlist))
|
||||
@@ -155,7 +166,18 @@
|
||||
@endforeach
|
||||
@endif
|
||||
</select>
|
||||
<input type="search" name="search" value="{{ $header_menu_search ?? '' }}" placeholder="Search releases..." class="w-full bg-gray-700 text-white text-sm px-3 py-3 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 touch-target">
|
||||
<div class="relative">
|
||||
<input type="search"
|
||||
name="search"
|
||||
id="mobile-search-input"
|
||||
value="{{ $header_menu_search ?? '' }}"
|
||||
placeholder="Search releases..."
|
||||
autocomplete="off"
|
||||
class="w-full bg-gray-700 text-white text-sm px-3 py-3 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 touch-target">
|
||||
<!-- Autocomplete dropdown for mobile -->
|
||||
<div id="mobile-autocomplete-dropdown" class="hidden absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="w-full bg-green-600 dark:bg-green-700 hover:bg-green-700 dark:hover:bg-green-800 text-white px-4 py-3 rounded transition touch-target font-semibold">
|
||||
<i class="fa fa-search mr-2"></i>Search
|
||||
</button>
|
||||
@@ -222,4 +244,3 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- NFO Modal - CSP Safe -->
|
||||
<div id="nfoModal" class="hidden fixed inset-0 z-50 overflow-y-auto" style="display: none;" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||
<div id="nfoModal" class="hidden fixed inset-0 z-50 overflow-y-auto modal-hidden" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<!-- Background overlay -->
|
||||
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-75 transition-opacity" aria-hidden="true" data-close-nfo-modal></div>
|
||||
|
||||
@@ -8,17 +8,36 @@
|
||||
</div>
|
||||
|
||||
<!-- Search Form -->
|
||||
<form method="GET" action="{{ route('search') }}" class="mb-8">
|
||||
<form method="GET" action="{{ route('search') }}" class="mb-8" id="searchForm">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-4">
|
||||
<!-- Search Query -->
|
||||
<div class="lg:col-span-2">
|
||||
<!-- Search Query with Autocomplete -->
|
||||
<div class="lg:col-span-2 relative">
|
||||
<label for="search" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Search Terms</label>
|
||||
<input type="text"
|
||||
id="search"
|
||||
name="search"
|
||||
value="{{ request('search') }}"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="Enter search terms...">
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
id="search"
|
||||
name="search"
|
||||
value="{{ request('search') }}"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="Enter search terms..."
|
||||
autocomplete="off"
|
||||
@if(isset($autocompleteEnabled) && $autocompleteEnabled) data-autocomplete="true" @endif>
|
||||
<!-- Autocomplete dropdown -->
|
||||
<div id="autocomplete-dropdown" class="hidden absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spell Suggestion ("Did you mean?") -->
|
||||
@if(isset($spellSuggestion) && !empty($spellSuggestion))
|
||||
<div class="mt-2 text-sm">
|
||||
<span class="text-gray-600 dark:text-gray-400">Did you mean: </span>
|
||||
<a href="{{ route('search', array_merge(request()->except('search'), ['search' => $spellSuggestion])) }}"
|
||||
class="text-blue-600 dark:text-blue-400 hover:underline font-medium">
|
||||
{{ $spellSuggestion }}
|
||||
</a>
|
||||
<span class="text-gray-500 dark:text-gray-500">?</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Category -->
|
||||
@@ -261,7 +280,7 @@
|
||||
{{ $result->category_name ?? 'Other' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400 dark:text-gray-400">
|
||||
<td class="px-3 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ userDateDiffForHumans($result->adddate) }}
|
||||
</td>
|
||||
<td class="px-3 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">
|
||||
@@ -402,7 +421,7 @@
|
||||
@endif
|
||||
|
||||
<!-- Preview/Sample Image Modal -->
|
||||
<div id="previewModal" class="hidden fixed inset-0 bg-black bg-opacity-75 items-center justify-center p-4" style="display: none; z-index: 9999 !important;">
|
||||
<div id="previewModal" class="hidden fixed inset-0 bg-black bg-opacity-75 items-center justify-center p-4 preview-modal-hidden">
|
||||
<div class="relative max-w-4xl w-full">
|
||||
<button type="button" data-close-preview-modal class="absolute top-4 right-4 text-white hover:text-gray-300 text-3xl font-bold z-10">
|
||||
<i class="fas fa-times"></i>
|
||||
|
||||
@@ -98,6 +98,11 @@ Route::get('2fa/verify', [PasswordSecurityController::class, 'getVerify2fa'])->n
|
||||
Route::post('2fa/verify', [PasswordSecurityController::class, 'verify2fa'])->name('2fa.post');
|
||||
Route::post('2faVerify', [PasswordSecurityController::class, 'verify2fa'])->name('2faVerify');
|
||||
|
||||
// Search autocomplete and suggest API routes (no auth required for better UX)
|
||||
Route::get('api/search/autocomplete', [\App\Http\Controllers\SearchSuggestController::class, 'autocomplete'])->name('api.search.autocomplete');
|
||||
Route::get('api/search/suggest', [\App\Http\Controllers\SearchSuggestController::class, 'suggest'])->name('api.search.suggest');
|
||||
Route::get('api/search/assist', [\App\Http\Controllers\SearchSuggestController::class, 'searchAssist'])->name('api.search.assist');
|
||||
|
||||
Route::middleware('isVerified')->group(function () {
|
||||
Route::match(['GET', 'POST'], 'resetpassword', [ResetPasswordController::class, 'reset'])->name('resetpassword');
|
||||
Route::match(['GET', 'POST'], 'profile', [ProfileController::class, 'show'])->name('profile');
|
||||
@@ -148,6 +153,8 @@ Route::middleware('isVerified')->group(function () {
|
||||
Route::match(['GET', 'POST'], 'profile_delete', [ProfileController::class, 'destroy'])->name('profile_delete');
|
||||
Route::post('profile/update-theme', [ProfileController::class, 'updateTheme'])->name('profile.update-theme');
|
||||
Route::match(['GET', 'POST'], 'search', [SearchController::class, 'search'])->name('search');
|
||||
|
||||
|
||||
Route::match(['GET', 'POST'], 'mymovies', [MyMoviesController::class, 'show'])->name('mymovies');
|
||||
Route::match(['GET', 'POST'], 'myshows', [MyShowsController::class, 'show'])->name('myshows');
|
||||
Route::match(['GET', 'POST'], 'myshows/browse', [MyShowsController::class, 'browse'])->name('myshows.browse');
|
||||
|
||||
Reference in New Issue
Block a user