Add autocomplete for search when using manticore

This commit is contained in:
DariusIII
2025-12-17 16:37:18 +01:00
parent e85316f28f
commit 1fb2e41b49
14 changed files with 1209 additions and 35 deletions
+13
View File
@@ -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
+547 -4
View File
@@ -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;
}
}
+29
View File
@@ -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);
}
}
+42
View File
@@ -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
+30
View File
@@ -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;
}
+216
View File
@@ -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');
}
});
}
}
+5 -5
View File
@@ -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' : '' }}>
&nbsp;&nbsp;{{ $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>
+8 -8
View File
@@ -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>
+27 -6
View File
@@ -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 -1
View File
@@ -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>
+30 -11
View File
@@ -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>
+7
View File
@@ -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');