180 lines
8.3 KiB
Text
180 lines
8.3 KiB
Text
|
|
---
|
|||
|
|
// Search.astro
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
<!-- Кнопка вызова -->
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
id="search-trigger"
|
|||
|
|
aria-label="Открыть поиск"
|
|||
|
|
class="group relative hidden w-full transform items-center justify-center p-2 text-center font-medium tracking-wide text-neutral-400 transition-transform duration-200 ease-out hover:scale-110 hover:text-white sm:mb-0 md:w-auto lg:flex"
|
|||
|
|
>
|
|||
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
<!-- Модальное окно -->
|
|||
|
|
<dialog id="search-modal" class="bg-transparent p-0 m-0 w-full h-full max-w-none max-h-none fixed inset-0 z-50 backdrop:bg-black/70 backdrop:backdrop-blur-sm open:animate-fade-in outline-none">
|
|||
|
|
|
|||
|
|
<div class="fixed inset-0 flex items-start justify-center p-4 pt-20" id="search-container">
|
|||
|
|
|
|||
|
|
<div class="relative w-full max-w-2xl rounded-2xl border border-neutral-800 bg-neutral-900 shadow-2xl overflow-hidden flex flex-col max-h-[80vh]" id="search-panel">
|
|||
|
|
|
|||
|
|
<!-- Кнопка закрытия -->
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
id="close-search"
|
|||
|
|
class="absolute right-5 top-5 z-20 flex h-10 w-10 items-center justify-center rounded-full bg-neutral-800 text-neutral-400 transition-all duration-200 hover:bg-neutral-700 hover:text-white hover:rotate-90 border border-neutral-700"
|
|||
|
|
>
|
|||
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
<!-- Контейнер инпута с ОГРОМНЫМ отступом сверху (pt-24 = 96px) -->
|
|||
|
|
<div class="px-8 pb-6 pt-24 border-b border-neutral-800 bg-neutral-900/50">
|
|||
|
|
|
|||
|
|
<div class="relative">
|
|||
|
|
<div class="absolute left-5 top-1/2 -translate-y-1/2 text-indigo-500">
|
|||
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
|
|||
|
|
</div>
|
|||
|
|
<input
|
|||
|
|
id="search-input"
|
|||
|
|
type="text"
|
|||
|
|
placeholder="Введите запрос..."
|
|||
|
|
class="w-full rounded-xl border border-neutral-700 bg-black/20 py-4 pl-14 pr-4 text-xl text-white placeholder:text-neutral-500 transition-all focus:border-indigo-500 focus:bg-neutral-950 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Результаты -->
|
|||
|
|
<div id="search-results" class="flex-1 overflow-y-auto p-4 space-y-2 min-h-[100px]">
|
|||
|
|
<p class="py-10 text-center text-neutral-500 text-lg">Начните вводить текст для поиска</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</dialog>
|
|||
|
|
|
|||
|
|
<!-- TEMPLATE и SCRIPT без изменений (как в прошлом ответе) -->
|
|||
|
|
<template id="search-result-template">
|
|||
|
|
<a href="" class="block rounded-lg p-4 transition-colors hover:bg-neutral-800 group border border-transparent hover:border-neutral-700">
|
|||
|
|
<h3 class="text-lg font-semibold text-white group-hover:text-indigo-400 transition-colors result-title"></h3>
|
|||
|
|
<p class="mt-1 text-sm text-neutral-400 line-clamp-2 result-desc"></p>
|
|||
|
|
</a>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<style>
|
|||
|
|
dialog[open] { animation: fadeIn 0.2s ease-out; }
|
|||
|
|
@keyframes fadeIn { from { opacity: 0; transform: scale(0.98); } to { opacity: 1; transform: scale(1); } }
|
|||
|
|
</style>
|
|||
|
|
|
|||
|
|
<!-- Оставьте HTML как есть, замените только секцию <script> -->
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
// Интерфейс для типизации (если используете TypeScript)
|
|||
|
|
interface SearchItem {
|
|||
|
|
id: string;
|
|||
|
|
title: string;
|
|||
|
|
description: string;
|
|||
|
|
slug: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const trigger = document.getElementById('search-trigger');
|
|||
|
|
const modal = document.getElementById('search-modal') as HTMLDialogElement | null;
|
|||
|
|
const closeBtn = document.getElementById('close-search');
|
|||
|
|
const container = document.getElementById('search-container');
|
|||
|
|
const input = document.getElementById('search-input') as HTMLInputElement | null;
|
|||
|
|
const resultsContainer = document.getElementById('search-results');
|
|||
|
|
const template = document.getElementById('search-result-template') as HTMLTemplateElement | null;
|
|||
|
|
|
|||
|
|
// Открытие модалки
|
|||
|
|
const openModal = () => {
|
|||
|
|
if (!modal) return;
|
|||
|
|
modal.showModal();
|
|||
|
|
document.body.style.overflow = 'hidden'; // Блокируем скролл страницы
|
|||
|
|
input?.focus();
|
|||
|
|
if (resultsContainer) resultsContainer.innerHTML = '<p class="py-10 text-center text-neutral-500">Начните вводить текст для поиска</p>';
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Закрытие модалки
|
|||
|
|
const closeModal = () => {
|
|||
|
|
if (!modal) return;
|
|||
|
|
modal.close();
|
|||
|
|
document.body.style.overflow = '';
|
|||
|
|
if (input) input.value = ''; // Очищаем поле
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Слушатели событий
|
|||
|
|
trigger?.addEventListener('click', openModal);
|
|||
|
|
closeBtn?.addEventListener('click', closeModal);
|
|||
|
|
|
|||
|
|
// Закрытие по клику вне области (на backdrop)
|
|||
|
|
modal?.addEventListener('click', (e) => {
|
|||
|
|
const rect = modal.getBoundingClientRect();
|
|||
|
|
// Проверяем, был ли клик внутри диалога или снаружи
|
|||
|
|
// (HTMLDialogElement работает специфично с backdrop)
|
|||
|
|
if (e.target === modal) {
|
|||
|
|
closeModal();
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Логика поиска
|
|||
|
|
let debounceTimer: ReturnType<typeof setTimeout>;
|
|||
|
|
|
|||
|
|
input?.addEventListener('input', (e: Event) => {
|
|||
|
|
const target = e.target as HTMLInputElement;
|
|||
|
|
const query = target.value.trim();
|
|||
|
|
|
|||
|
|
// Сброс таймера
|
|||
|
|
clearTimeout(debounceTimer);
|
|||
|
|
|
|||
|
|
// Очистка если пусто
|
|||
|
|
if (!query) {
|
|||
|
|
if (resultsContainer) resultsContainer.innerHTML = '<p class="py-10 text-center text-neutral-500">Начните вводить текст</p>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Задержка перед запросом (Debounce)
|
|||
|
|
debounceTimer = setTimeout(async () => {
|
|||
|
|
try {
|
|||
|
|
if (resultsContainer) resultsContainer.innerHTML = '<div class="py-10 text-center text-neutral-500">Поиск...</div>';
|
|||
|
|
|
|||
|
|
// ! ВАЖНО: Запрос идет на наш созданный API
|
|||
|
|
const res = await fetch(`/api/search.json?q=${encodeURIComponent(query)}`);
|
|||
|
|
|
|||
|
|
if (!res.ok) throw new Error('Network error');
|
|||
|
|
|
|||
|
|
const data: SearchItem[] = await res.json();
|
|||
|
|
|
|||
|
|
// Очистка контейнера перед вставкой
|
|||
|
|
if (resultsContainer) resultsContainer.innerHTML = '';
|
|||
|
|
|
|||
|
|
if (data.length === 0) {
|
|||
|
|
if (resultsContainer) resultsContainer.innerHTML = '<p class="py-10 text-center text-neutral-400">Ничего не найдено</p>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Рендер результатов
|
|||
|
|
data.forEach(item => {
|
|||
|
|
if (template) {
|
|||
|
|
const clone = template.content.cloneNode(true) as DocumentFragment;
|
|||
|
|
|
|||
|
|
const link = clone.querySelector('a');
|
|||
|
|
const titleEl = clone.querySelector('.result-title');
|
|||
|
|
const descEl = clone.querySelector('.result-desc');
|
|||
|
|
|
|||
|
|
// Подставляем данные. Убедитесь, что пути URL совпадают с вашей структурой (/blog/ или /posts/)
|
|||
|
|
if (link) link.href = `/blog/${item.slug}`;
|
|||
|
|
if (titleEl) titleEl.textContent = item.title;
|
|||
|
|
|
|||
|
|
// Если описания нет, можно не выводить
|
|||
|
|
if (descEl) descEl.textContent = item.description || '';
|
|||
|
|
|
|||
|
|
if (resultsContainer) resultsContainer.appendChild(clone);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Ошибка:', error);
|
|||
|
|
if (resultsContainer) resultsContainer.innerHTML = '<p class="py-10 text-center text-red-400">Ошибка при поиске</p>';
|
|||
|
|
}
|
|||
|
|
}, 300); // Ждем 300мс после ввода последней буквы
|
|||
|
|
});
|
|||
|
|
</script>
|