/
Jenison4ik
/
SeaSearch_Alt
Обзор
Документация
Войти
/
Jenison4ik
/
SeaSearch_Alt
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
app.js
362 строки
13 KB
Jenison4ik
first_commit
04 мар 2026, 05:34
04 мар 2026, 05:34
d740bbc
Код
Авторство
О чём код?
/* ============================================================= SeaSearch — Ванильный JS для отображения мест у воды ============================================================= Основная логика: 1. Загружаем данные из Google Sheets API (GET-запрос) 2. Рендерим карточки мест в список 3. Фильтруем по типу (Пляж / Бухта / Гавань / Все) 4. Показываем детальную информацию в модальном окне 5. Markdown-описание рендерим через Showdown.js ============================================================= */ /* ========================= КОНСТАНТЫ И КОНФИГУРАЦИЯ ========================= */ /** * Базовый URL для запросов к Google Sheets API. * Используется только GET — ключ не нужен. */ var API_URL = 'https://script.googleusercontent.com/macros/echo?user_content_key=AY5xjrTharwtCtIS2_PwUdBfEVkk_rqwNUUsjLUwPWVS7jSat6IMaS4agQPy6mqWdmIRUwzl3JcSyFf9eMUBUA57OxbG600dONVVQHH9U_KDpjnVzOnm4bZ0Z2M268yUrJtXBznojw37cYTJGLq8CQv79TOwaQOrUkIwk5tiLBDnbQjZ2GZmFS9er_wjnUhM4cAl_SlL9g_KRA1_dqEko8pN_L1gKG9mfh2QxC6BGn29kLWgNo-AOoYAJ47Uvu_LYZt9oipWt3ciwDexDsKl-MvEYhEMfzRVDTme5-YYM6rs&lib=M7FhK4Wyv4Ld5yM9jfkfRP6HsPCSGXZuo'; /** * Маппинг типов мест на CSS-классы бейджей. * Ключ — значение поля «type» из API, * значение — суффикс для класса .badge--* */ var BADGE_CLASS_MAP = { 'Пляж': 'beach', 'Бухта': 'bay', 'Гавань': 'harbor', }; /** * Заглушка для карточек, у которых нет фото. * SVG inline data URI — серый прямоугольник с текстом «Нет фото». */ var PLACEHOLDER_IMAGE = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='250' viewBox='0 0 400 250'%3E%3Crect fill='%23e2e8f0' width='400' height='250'/%3E%3Ctext fill='%23a0aec0' font-family='sans-serif' font-size='14' x='50%25' y='50%25' text-anchor='middle' dy='.3em'%3EНет фото%3C/text%3E%3C/svg%3E"; /** * Экземпляр Showdown-конвертера для Markdown → HTML. * Создаётся один раз, переиспользуется при каждом открытии модалки. * Опции: * - tables: поддержка таблиц * - simplifiedAutoLink: автоссылки без угловых скобок * - strikethrough: зачёркивание ~~текст~~ */ var mdConverter = new showdown.Converter({ tables: true, simplifiedAutoLink: true, strikethrough: true, }); /* ========================= СОСТОЯНИЕ ПРИЛОЖЕНИЯ ========================= */ /** Массив всех мест, загруженных из API */ var allPlaces = []; /** Текущий активный фильтр по типу. Пустая строка = показать все */ var activeFilter = ''; /* ========================= DOM-ЭЛЕМЕНТЫ Получаем ссылки один раз при загрузке, чтобы не искать каждый раз через querySelector ========================= */ var loaderEl = document.getElementById('loader'); var errorEl = document.getElementById('error'); var retryBtn = document.getElementById('retryBtn'); var cardsEl = document.getElementById('cards'); var emptyEl = document.getElementById('empty'); /* Элементы модального окна */ var modalEl = document.getElementById('modal'); var modalOverlay = document.getElementById('modalOverlay'); var modalClose = document.getElementById('modalClose'); var modalImage = document.getElementById('modalImage'); var modalBadge = document.getElementById('modalBadge'); var modalTitle = document.getElementById('modalTitle'); var modalAddress = document.getElementById('modalAddress'); var modalDescription = document.getElementById('modalDescription'); /* Кнопки фильтров */ var filterButtons = document.querySelectorAll('.filters__btn'); /* ============================================================= ЗАГРУЗКА ДАННЫХ ИЗ API ============================================================= */ /** * Загружает все места из Google Sheets API. * Показывает лоадер во время загрузки, обрабатывает ошибки. */ async function fetchPlaces() { /* Показываем лоадер, прячем ошибку и карточки */ showLoader(true); errorEl.hidden = true; cardsEl.innerHTML = ''; emptyEl.hidden = true; try { /* Выполняем GET-запрос к API */ var response = await fetch(API_URL); /* Проверяем HTTP-статус */ if (!response.ok) { throw new Error('HTTP ошибка: ' + response.status); } /* Парсим JSON-ответ */ var json = await response.json(); /* Проверяем, что API вернул успешный статус и массив данных */ if (json.status !== 'ok' || !Array.isArray(json.data)) { throw new Error('Некорректный формат ответа API'); } /* Сохраняем данные в глобальную переменную */ allPlaces = json.data; /* Прячем лоадер */ showLoader(false); /* Рендерим карточки с учётом текущего фильтра */ renderCards(); } catch (error) { /* В случае ошибки — прячем лоадер и показываем сообщение */ console.error('Ошибка загрузки данных:', error); showLoader(false); errorEl.hidden = false; } } /** * Показывает или прячет лоадер. * Используем style.display напрямую, потому что CSS `display: flex` * на .loader перебивает стандартный атрибут hidden. * * @param {boolean} visible — true = показать, false = скрыть */ function showLoader(visible) { loaderEl.style.display = visible ? 'flex' : 'none'; } /* ============================================================= РЕНДЕРИНГ КАРТОЧЕК ============================================================= */ /** * Фильтрует места по текущему activeFilter и * рендерит карточки в контейнер #cards. */ function renderCards() { /* Очищаем контейнер карточек */ cardsEl.innerHTML = ''; emptyEl.hidden = true; /* Фильтруем массив мест — если activeFilter пустой, показываем все */ var filtered = activeFilter ? allPlaces.filter(function (place) { return place.type === activeFilter; }) : allPlaces; /* Если после фильтрации ничего нет — показываем «пусто» */ if (filtered.length === 0) { emptyEl.hidden = false; return; } /* Создаём карточку для каждого места */ filtered.forEach(function (place) { var card = createCardElement(place); cardsEl.appendChild(card); }); } /** * Создаёт DOM-элемент карточки для одного места. * * @param {Object} place — объект места из API * @returns {HTMLElement} — готовый DOM-элемент <article> */ function createCardElement(place) { /* Корневой элемент карточки */ var card = document.createElement('article'); card.className = 'card'; /* URL изображения (заглушка, если фото нет) */ var imageUrl = place.photo_url && place.photo_url.trim() ? place.photo_url : PLACEHOLDER_IMAGE; /* CSS-класс бейджа по типу места */ var badgeClass = BADGE_CLASS_MAP[place.type] || 'beach'; /* Адрес для карточки (если есть и не прочерк) */ var shortAddress = place.address && place.address.trim() && place.address !== '-' ? place.address : ''; /* Заполняем HTML карточки */ card.innerHTML = '<div class="card__image-wrap">' + '<img class="card__image" src="' + escapeHtml(imageUrl) + '" alt="' + escapeHtml(place.name) + '" loading="lazy">' + '</div>' + '<div class="card__body">' + '<span class="badge badge--' + badgeClass + '">' + escapeHtml(place.type) + '</span>' + '<h3 class="card__title">' + escapeHtml(place.name) + '</h3>' + (shortAddress ? '<p class="card__address">' + escapeHtml(shortAddress) + '</p>' : '') + '</div>'; /* Обработчик клика — открываем модальное окно с деталями */ card.addEventListener('click', function () { openModal(place); }); return card; } /* ============================================================= МОДАЛЬНОЕ ОКНО (ДЕТАЛЬНЫЙ ПРОСМОТР) ============================================================= */ /** * Открывает модальное окно и заполняет его данными выбранного места. * * @param {Object} place — объект места из API */ function openModal(place) { /* Устанавливаем изображение */ var imageUrl = place.photo_url && place.photo_url.trim() ? place.photo_url : PLACEHOLDER_IMAGE; modalImage.src = imageUrl; modalImage.alt = place.name; /* Устанавливаем бейдж типа */ var badgeClass = BADGE_CLASS_MAP[place.type] || 'beach'; modalBadge.className = 'badge badge--' + badgeClass; modalBadge.textContent = place.type; /* Устанавливаем название */ modalTitle.textContent = place.name; /* Устанавливаем адрес (если есть) */ if (place.address && place.address.trim() && place.address !== '-') { modalAddress.textContent = place.address; modalAddress.hidden = false; } else { modalAddress.textContent = ''; modalAddress.hidden = true; } /* Рендерим описание из Markdown через Showdown.js */ if (place.description && place.description.trim()) { modalDescription.innerHTML = mdConverter.makeHtml(place.description); } else { modalDescription.innerHTML = '<p>Описание пока не добавлено.</p>'; } /* Показываем модалку */ modalEl.hidden = false; /* Блокируем прокрутку основной страницы */ document.body.style.overflow = 'hidden'; } /** * Закрывает модальное окно. */ function closeModal() { modalEl.hidden = true; /* Возвращаем прокрутку страницы */ document.body.style.overflow = ''; } /* ============================================================= УТИЛИТЫ ============================================================= */ /** * Экранирует HTML-спецсимволы для предотвращения XSS. * * @param {string} str — исходная строка * @returns {string} — экранированная строка */ function escapeHtml(str) { if (!str) return ''; var s = String(str); return s .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /* ============================================================= ОБРАБОТЧИКИ СОБЫТИЙ ============================================================= */ /** * Клики по кнопкам фильтров. * При клике — обновляем activeFilter и перерендериваем карточки. */ filterButtons.forEach(function (btn) { btn.addEventListener('click', function () { /* Снимаем класс «активный» со всех кнопок */ filterButtons.forEach(function (b) { b.classList.remove('filters__btn--active'); }); /* Ставим класс «активный» на нажатую кнопку */ btn.classList.add('filters__btn--active'); /* Обновляем фильтр (data-type из HTML) */ activeFilter = btn.dataset.type; /* Перерисовываем карточки */ renderCards(); }); }); /* Закрытие модалки — по клику на оверлей */ modalOverlay.addEventListener('click', closeModal); /* Закрытие модалки — по клику на крестик */ modalClose.addEventListener('click', closeModal); /* Закрытие модалки — по нажатию Escape */ document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !modalEl.hidden) { closeModal(); } }); /* Кнопка «Повторить» — повторная загрузка данных при ошибке */ retryBtn.addEventListener('click', fetchPlaces); /* ============================================================= ЗАПУСК ПРИЛОЖЕНИЯ Скрипт подключён в конце <body>, DOM уже готов. ============================================================= */ fetchPlaces();