Анализ сайта aphotostudent.com
Основное Готовность: 40%
Домен
aphotostudent.com
Состояние доменного имени
?
Проверяем корректность доменного имени и наличие технических проблем на уровне домена.
Длина домена велика. Но если вы продвигаете запрос, входящий в название домена, то это хорошо.
Домен второго уровня идеален для продвижения.
Ответ сервера
200 Успешный ответ
HTTP-код ответа и цепочка редиректов
?
Код 200 — страница доступна. Коды 3xx — редиректы (цепочки замедляют загрузку и размывают ссылочный вес). Коды 4xx/5xx — ошибки, поисковик не сможет проиндексировать страницу.
Кол-во редиректов 3 слишком большое! Проверьте настройки сайта и веб-сервера!
Цепочка редиректов:
http://aphotostudent.com
301 MovedPermanently
http://www.jamespomerantz.com/
301 MovedPermanently
https://www.jamespomerantz.com/
200 OK
Безопасность
Сайт безопасен
Использование HTTPS и SSL-сертификат
?
HTTPS — обязательный стандарт. Google и Яндекс отдают предпочтение защищённым сайтам. Отсутствие SSL или просроченный сертификат ведут к предупреждениям в браузере и снижению позиций.
Не настроен HSTS (Strict-Transport-Security) — рекомендуется включить.
На сайте работает защищенный протокол ssl и сайт открывается по https.
Ssl-сертификат действителен до 21.11.2026 3:16:42.
HTTP автоматически перенаправляется на HTTPS.
Поздравляем! Сайт не содержится в реестре РКН.
Кодировка
UTF-8
Кодировка символов страницы
?
Стандарт — UTF-8. Неправильная кодировка вызывает нечитаемые символы и мешает поисковику корректно распознать текст страницы.
Указана кодировка на странице UTF-8.
Язык
en
Атрибут lang в HTML-теге
?
Атрибут lang (<html lang="ru">) сообщает поисковикам и браузерам, на каком языке написана страница. Помогает при ранжировании в региональном поиске.
Язык документа указан явно: en.
Скорость загрузки
~1,94сек
Время отклика сервера (TTFB)
?
Time To First Byte — время до получения первого байта от сервера. Норма до 200 мс. Медленный отклик ухудшает пользовательский опыт и ранжирование: Яндекс и Google учитывают скорость страниц.
Скорость загрузки сайта 1,94сек превышает 1 секунду. Желательно улучшить работу сайта!
Объем документа
780Кб
Размер HTML-кода страницы
?
Слишком большой HTML замедляет парсинг браузером и сканирование поисковым роботом. Рекомендуется не более 200 Кб.
Объем html-документа 780Кб оптимален.
Структура html-документа корректна.
Ресурсы
Ресурсы: 18
Внешние ресурсы страницы (CSS, JS, изображения)
?
Количество и тип подключённых ресурсов влияют на скорость загрузки. Большое число запросов увеличивает время рендеринга страницы.
Кол-во файлов ресурсов 18 много для одной страницы. Приемлемо до 10. Проведите оптимизацию файлов ресурсов!
Показать полный список ресурсов
| Тип | Название | Значение |
|---|---|---|
| stylesheet | https://cdn.neonsky.app/menu-styles-v260814-001.css?t=1787453514227 | |
| stylesheet | https://cdn.neonsky.app/palette-explorer-v260814-001.css?t=1787453514227 | |
| stylesheet | https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.min.css | |
| stylesheet | https://cdn.neonsky.app/page-styles-v260814-001.css?t=1787453514227 | |
| js | https://assets.lemonsqueezy.com/lemon.js | |
| js | https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js | |
| js | https://cdn.jsdelivr.net/npm/magic-sdk | |
| js | https://cdn.jsdelivr.net/npm/@magic-ext/auth | |
| js | https://cdn.neonsky.app/style-customizer-v260814-001.js?t=1787453514227 | |
| js | https://cdn.neonsky.app/palette-explorer-v260814-001.js?t=1787453514227 | |
| js | https://cdn.neonsky.app/sidebar-manager-v260814-001.js?t=1787453514227 | |
| js | https://cdn.neonsky.app/image-processor-v260814-001.js?t=1787453514227 | |
| js | https://cdn.neonsky.app/image-uploader-v260814-001.js?t=1787453514227 | |
| js | https://cdn.neonsky.app/page-layout-presets-v260814-001.js?t=1787453514227 | |
| js | https://cdn.neonsky.app/site-graph-presets-v260814-001.js?t=1787453514227 | |
| js | https://cdn.neonsky.app/site-graph-sync-v260814-001.js?t=1787453514227 | |
| js | https://cdn.neonsky.app/page-manager-v260814-001.js?t=1787453514227 | |
| js | module | https://static.cloudflareinsights.com/beacon.min.js/v4513226cdae34746b4dedf0b4dfa099e1781791509496 |
Серверные заголовки
Кол-во: 11
HTTP-заголовки ответа сервера
?
Заголовки сервера передают браузеру и поисковику служебную информацию: кеширование, безопасность (CSP, HSTS), сжатие (gzip). Правильная настройка ускоряет загрузку и повышает защищённость.
Найдены серверные заголовки 11шт. Подробнее про серверные заголовки.
Показать полный список серверных заголовков
| Ключ | Значение |
|---|---|
| Date | Sun, 23 Aug 2026 02:51:54 GMT |
| Server-Timing | cfEdge;dur=104,cfOrigin;dur=0,cfWorker;dur=1184 |
| Cache-Control | no-store, must-revalidate, proxy-revalidate, no-cache |
| Pragma | no-cache |
| X-Content-Type-Options | nosniff |
| X-Frame-Options | SAMEORIGIN |
| X-XSS-Protection | 1; mode=block |
| report-to | {"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=Q25WU8diZdStv%2FaYUpl9iSlBNtXGlay2Dl2VdNJjeHeMSP8pN0tYX%2FRFD1WS6hB3eRlIz5MwUqhh8MFVeyEdZBIRxB7fjgKa6L8Dhwirbo8jT8iVg1p4Wb%2BrYo%2Ffu2eSitIDcuj3Sms%3D"}]} |
| nel | {"report_to":"cf-nel","success_fraction":0.0,"max_age":604800} |
| Server | cloudflare |
| cf-ray | a2f6d168083af147-DME |
CMS
Не определена
Система управления сайтом (движок)
?
CMS — это движок, на котором работает сайт (WordPress, 1C-Bitrix, Tilda и др.). Знание CMS помогает понять возможности SEO-оптимизации и подобрать подходящие инструменты. «Не определена» — вероятно, самописный сайт или нестандартная сборка.
CMS не определена. Вероятно, сайт самописный либо движок надёжно скрыт. Это не ошибка.
Веб-сервер
Программное обеспечение сервера
?
Веб-сервер — это ПО, которое отдаёт страницы посетителям (nginx, Apache, IIS, LiteSpeed и др.). Определяется по серверным заголовкам ответа (Server, X-Powered-By и т.п.). «Не определён» — сервер намеренно скрывает эти заголовки, это нормальная практика безопасности.
Сайт работает на веб-сервере Cloudflare.
Мета-теги Готовность: 23%
Title
jamespomerantz.com
Заголовок страницы в браузере и поисковой выдаче
?
Title — главный SEO-заголовок страницы. Влияет на CTR в поиске и ранжирование. Оптимальная длина: 50–70 символов. Ключевые слова — ближе к началу.
Необходимо увеличить число символов в title (текущее значение мало: 18, минимум: 25, оптимально: от 40 до 45)
Дублей словоформ в title не найдено.
Description
Описание страницы в поисковой выдаче (сниппет)
?
Meta Description — текст под заголовком в выдаче. Напрямую на позиции не влияет, но влияет на CTR. Оптимальная длина: 120–160 символов.
Установите мета-тег description!
Keywords
Список ключевых слов страницы (устаревший тег)
?
Meta Keywords не учитывается Яндексом и Google для ранжирования с 2009–2012 годов. Заполнение не обязательно, но не вредит. Конкурент может использовать содержимое для анализа.
Установите мета-тег keywords!
Канонический Url
https://www.jamespomerantz.com/
Указывает поисковику основную версию страницы
?
Canonical (rel=canonical) предотвращает проблему дублей страниц. Должен точно совпадать с URL проверяемой страницы. Неправильный canonical может передать ссылочный вес на другую страницу.
Канонический Url прописан корректно.
Robots
index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1
Директивы для поисковых роботов на уровне страницы
?
Meta Robots управляет индексацией конкретной страницы: index/noindex — индексировать ли, follow/nofollow — следовать ли по ссылкам. Noindex полностью исключает страницу из поиска.
Meta-тег robots со значениями index, follow не накладывает ограничений на индексирование и показ контента.
Адаптивность
width=device-width, initial-scale=1.0
Настройка масштабирования на мобильных устройствах
?
Тег viewport (<meta name="viewport">) сообщает браузеру, как масштабировать страницу на мобильных. Стандарт: width=device-width, initial-scale=1. Отсутствие — признак отсутствия мобильной версии.
Meta-тег viewport со значением-константой width=device-width задаёт ширину страницы в соответствии с размером экрана.
Meta-тег viewport со значением initial-scale=1.0 определяет масштаб 1:1, т.е. «не масштабировать».
Разметка OpenGraph
Кол-во: 6
Мета-теги для красивых превью в соцсетях
?
OpenGraph (og:title, og:description, og:image) управляет тем, как страница выглядит при репосте в социальных сетях и мессенджерах. Отсутствие OG-тегов — невзрачный превью при шеринге.
Разметка OpenGraph задана. Страница оптимизирована под социальные сети.
Показать полный список og мета-тегов
| Тип | Значение |
|---|---|
| og:type | website |
| og:url | https://www.jamespomerantz.com/ |
| og:title | jamespomerantz.com |
| og:description | |
| og:site_name | jamespomerantz.com |
| og:locale | en_US |
Все мета-теги
Кол-во: 21
Полный список мета-тегов страницы
?
Таблица всех meta-тегов, включая нестандартные. Позволяет найти опечатки, дубли и лишние теги.
Найдены мета-теги 21шт. Мета-теги не видимы для человека и предназначены для обмена информацией между веб-страницей и поисковыми системами, браузерами и другими веб-службами. С ними роботы 🤖 и устройства ведут себя более ожидаемо.
Показать полный список мета-тегов
| Тип | Название | Значение |
|---|---|---|
| name | viewport | width=device-width, initial-scale=1.0 |
| name | hydra-site-id | y260f4a7 |
| name | robots | index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1 |
| name | author | jamespomerantz.com |
| name | language | en |
| name | twitter:card | summary_large_image |
| name | twitter:site | @jamespomerantz.com |
| name | twitter:url | https://www.jamespomerantz.com/ |
| name | twitter:title | jamespomerantz.com |
| name | twitter:description | |
| name | theme-color | #000000 |
| name | msapplication-TileColor | #000000 |
| name | apple-mobile-web-app-capable | yes |
| name | apple-mobile-web-app-status-bar-style | default |
| name | apple-mobile-web-app-title | jamespomerantz.com |
| property | og:type | website |
| property | og:url | https://www.jamespomerantz.com/ |
| property | og:title | jamespomerantz.com |
| property | og:description | |
| property | og:site_name | jamespomerantz.com |
| property | og:locale | en_US |
Оптимизация Готовность: 35%
Структура
Ошибок нет
Семантические HTML-элементы страницы
?
Проверяет наличие основных структурных элементов: nav, header, footer, main. Корректная семантическая структура помогает поисковику понять архитектуру страницы.
Структура документа корректна (теги <html> и <body> присутствуют по одному на документ).
Контент
Есть ошибки
Объём и качество текстового содержимого
?
Анализирует объём полезного текста на странице. Слишком мало — страница может считаться малополезной. Слишком много — ухудшается читаемость и восприятие.
Слова из title 0 встречаются в тексте редко. Добавьте в контент страницы слова из тега <title>!
Абзацев с текстом 8 слишком мало. Добавьте больше абзацев с текстом (тег <p>)!
Кол-во слов 590 не очень много. Добавьте побольше текста (хотя бы 800 слов)!
Среднее число слов в абзаце 26 достаточно.
Кол-во знаков контента 3682 на странице оптимально.
Заголовки
Ошибок нет
Иерархия заголовков H1–H6
?
H1 должен быть один и содержать ключевой запрос. H2–H6 описывают подразделы. Пропуск уровней (H1 → H3) и несколько H1 — типичные ошибки, снижающие понятность страницы для поисковика.
На странице присутствуют заголовки <h3> 2.
Тошнота
2,45
Насколько одно слово доминирует в тексте
?
Классическая тошнота = √(частота самого повторяющегося слова). Норма до 7–8: текст воспринимается естественно. Выше — поисковик может счесть страницу переспамленной.
Тошнота страницы в пределах нормы 3.
Академич. тошнота
16,61%
Насколько текст перенасыщен ключевыми словами
?
Академическая тошнота = (частота слова / общее количество слов) × 100%. Показывает долю конкретного слова в тексте. Норма 5–15%.
Академическая тошнота превышает норму 5-15%. Измените текст страницы!
Семантическое ядро
20
Наиболее часто встречающиеся слова на странице
?
Топ слов по частоте использования. Показывает, какие слова доминируют в тексте с точки зрения поисковика.
Контент страницы содержит осмысленный текст и слова.
Показать список слов
| Слово | Кол-во | Частота |
|---|---|---|
| upload | 6 | 1,02% |
| search | 5 | 0,85% |
| password | 5 | 0,85% |
| sidebar | 4 | 0,68% |
| gallery | 4 | 0,68% |
| copyright | 4 | 0,68% |
| import | 4 | 0,68% |
| classic | 4 | 0,68% |
| content | 3 | 0,51% |
| element | 3 | 0,51% |
| select | 3 | 0,51% |
| cancel | 3 | 0,51% |
| metadata | 3 | 0,51% |
| results | 3 | 0,51% |
| recommended | 3 | 0,51% |
| 3 | 0,51% | |
| language | 3 | 0,51% |
| enabled | 3 | 0,51% |
| favicon | 3 | 0,51% |
| browser | 3 | 0,51% |
Индексация Готовность: 30%
Индексирование
Есть ошибки
Разрешено ли индексирование страницы
?
Проверяет, не закрыта ли страница от индексации через robots.txt, meta robots или X-Robots-Tag. Страница, закрытая от индексации, не появится в поисковой выдаче.
Анкоров на странице 2 слишком мало или они запрещены к индексации. Добавьте новые страницы на сайт и расставьте ссылки на них на главной странице и в файле robots.txt.
Robots.txt
Найден корректный robots.txt
Файл управления сканированием сайта роботами
?
Robots.txt указывает поисковым роботам, какие страницы сканировать, а какие — нет. Ошибки в файле могут случайно закрыть важные разделы от индексации.
Кол-во редиректов для файла robots.txt 3 слишком большое! Это может вызывать затруднение при индексации поисковыми роботами!
Проверяемая страница не запрещена в robots.txt.
Robots.txt доступен по постоянному адресу
Цепочка редиректов для файла robots.txt:
http://aphotostudent.com/robots.txt
301 MovedPermanently
http://www.jamespomerantz.com/
301 MovedPermanently
https://www.jamespomerantz.com/
200 OK
Показать содержимое robots.txt
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="hydra-site-id" content="y260f4a7">
<!-- Primary Meta Tags -->
<title>jamespomerantz.com</title>
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1">
<meta name="author" content="jamespomerantz.com">
<meta name="language" content="en">
<!-- Canonical URL -->
<link rel="canonical" href="https://www.jamespomerantz.com/">
<!-- Favicon -->
<!-- Open Graph / Facebook / iMessage / Discord -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://www.jamespomerantz.com/">
<meta property="og:title" content="jamespomerantz.com">
<meta property="og:description" content="">
<meta property="og:site_name" content="jamespomerantz.com">
<meta property="og:locale" content="en_US">
<!-- Twitter / X Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@jamespomerantz.com">
<meta name="twitter:url" content="https://www.jamespomerantz.com/">
<meta name="twitter:title" content="jamespomerantz.com">
<meta name="twitter:description" content="">
<!-- Additional SEO Meta Tags -->
<meta name="theme-color" content="#000000">
<meta name="msapplication-TileColor" content="#000000">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="jamespomerantz.com">
<!-- Security Headers -->
<meta http-equiv="X-Content-Type-Options" content="nosniff">
<meta http-equiv="X-XSS-Protection" content="1; mode=block">
<style>
/* FOUC gate: opacity on <html> cannot be pierced by child visibility:!important (submenu CSS) */
html:not(.hydra-ready) { opacity: 0 !important; }
/* Critical: hide nested menus before menu-styles.css arrives */
li:not(.expanded) > ol.nested-sortable { display: none !important; }
.hydra-seo-nav { position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0; }
.hydra-seo-nav ul { list-style:none;margin:0;padding:0; }
.hydra-skip-link { position:absolute;left:-10000px;top:auto;width:1px;height:1px;overflow:hidden; }
</style>
<noscript><style>html { opacity: 1 !important; }</style></noscript>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebPage",
"name": "jamespomerantz.com",
"description": "",
"url": "https://www.jamespomerantz.com/",
"datePublished": "2026-08-23T02:51:54.123Z",
"dateModified": "2026-08-23T02:51:54.123Z",
"inLanguage": "en-US",
"isPartOf": {
"@type": "WebSite",
"@id": "https://jamespomerantz.com/#website",
"name": "jamespomerantz.com",
"url": "https://jamespomerantz.com/",
"description": "",
"publisher": {
"@type": "Organization",
"name": "jamespomerantz.com",
"url": "https://jamespomerantz.com/"
}
},
"breadcrumb": {
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": "https://jamespomerantz.com/"
},
{
"@type": "ListItem",
"position": 2,
"name": "jamespomerantz.com",
"item": "https://www.jamespomerantz.com/"
}
]
}
}
</script>
<!-- Preview mode helper script -->
<script>
// Immediately execute to patch request methods before any other script runs
(function() {
// Detect preview mode by checking hostname
const isPreviewMode = window.location.hostname === 'preview.neonsky.app';
if (isPreviewMode) {
console.log("[Preview Helper] Preview mode detected");
// Extract GUID from URL path (first segment)
const pathParts = window.location.pathname.split('/').filter(Boolean);
const previewGuid = pathParts.length > 0 ? pathParts[0] : '';
if (!previewGuid) {
console.warn("[Preview Helper] No GUID found in path:", window.location.pathname);
return; // Exit if no GUID found
}
console.log("[Preview Helper] Using GUID:", previewGuid);
// Store these in window for other scripts
window.isPreviewMode = true;
window.previewGuid = previewGuid;
window.isHydraEnvironment = true;
window.Parameters = window.Parameters || {};
window.Parameters.isHydra = true;
// Helper function that adds the GUID to API URLs and relative links
window.getApiUrl = function(endpoint) {
// For relative paths
if (endpoint.startsWith('/api/')) {
const newUrl = '/' + previewGuid + endpoint;
console.log("[Preview Helper] Transformed API URL:", endpoint, "→", newUrl);
return newUrl;
}
return endpoint;
};
// Helper function that adds the GUID to any relative link
window.getPreviewUrl = function(url) {
// For relative paths (starts with /) that aren't already prefixed with GUID
if (url.startsWith('/') && !url.startsWith('/' + previewGuid + '/')) {
const newUrl = '/' + previewGuid + url;
console.log("[Preview Helper] Transformed relative link:", url, "→", newUrl);
return newUrl;
}
return url;
};
// PATCH FETCH API with enhanced URL handling
const originalFetch = window.fetch;
window.fetch = function(resource, init) {
// Only transform string URLs
if (typeof resource === 'string') {
// Case 1: Handle relative paths starting with /api/
if (resource.startsWith('/api/')) {
const newUrl = window.getApiUrl(resource);
console.log("[Preview Helper] Patched fetch call (API):", resource, "→", newUrl);
return originalFetch(newUrl, init);
}
// Case 2: Handle other relative paths (for content/gallery links)
else if (resource.startsWith('/') && !resource.startsWith('/' + previewGuid + '/')) {
const newUrl = window.getPreviewUrl(resource);
console.log("[Preview Helper] Patched fetch call (relative):", resource, "→", newUrl);
return originalFetch(newUrl, init);
}
// Case 3: Handle absolute URLs to the same domain containing /api/
else if (resource.includes(window.location.host) && resource.includes('/api/')) {
try {
const urlObj = new URL(resource);
if (urlObj.hostname === window.location.hostname) {
const path = urlObj.pathname;
// Already has /{guid}/api/... (e.g. from new URL(getApiUrl(...), origin)) — do not double-prefix
if (path.startsWith('/' + previewGuid + '/')) {
return originalFetch(resource, init);
}
const newPath = '/' + previewGuid + path;
urlObj.pathname = newPath;
const newUrl = urlObj.toString();
console.log("[Preview Helper] Patched fetch call (absolute API):", resource, "→", newUrl);
return originalFetch(newUrl, init);
}
} catch (e) {
console.error("[Preview Helper] Error processing URL:", e);
}
}
// Case 4: Handle absolute URLs to the same domain (non-API)
else if (resource.includes(window.location.host)) {
try {
const urlObj = new URL(resource);
if (urlObj.hostname === window.location.hostname &&
!urlObj.pathname.startsWith('/' + previewGuid + '/')) {
const path = urlObj.pathname;
const newPath = '/' + previewGuid + path;
urlObj.pathname = newPath;
const newUrl = urlObj.toString();
console.log("[Preview Helper] Patched fetch call (absolute):", resource, "→", newUrl);
return originalFetch(newUrl, init);
}
} catch (e) {
console.error("[Preview Helper] Error processing URL:", e);
}
}
}
return originalFetch(resource, init);
};
console.log("[Preview Helper] Patched fetch API");
// PATCH XMLHttpRequest with enhanced URL handling
const originalOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(method, url, ...rest) {
// Only transform string URLs
if (typeof url === 'string') {
// Case 1: Handle relative paths starting with /api/
if (url.startsWith('/api/')) {
const newUrl = window.getApiUrl(url);
console.log("[Preview Helper] Patched XMLHttpRequest (API):", url, "→", newUrl);
return originalOpen.call(this, method, newUrl, ...rest);
}
// Case 2: Handle other relative paths
else if (url.startsWith('/') && !url.startsWith('/' + previewGuid + '/')) {
const newUrl = window.getPreviewUrl(url);
console.log("[Preview Helper] Patched XMLHttpRequest (relative):", url, "→", newUrl);
return originalOpen.call(this, method, newUrl, ...rest);
}
// Case 3: Handle absolute URLs to the same domain containing /api/
else if (url.includes(window.location.host) && url.includes('/api/')) {
try {
const urlObj = new URL(url);
if (urlObj.hostname === window.location.hostname) {
const path = urlObj.pathname;
if (path.startsWith('/' + previewGuid + '/')) {
return originalOpen.call(this, method, url, ...rest);
}
const newPath = '/' + previewGuid + path;
urlObj.pathname = newPath;
const newUrl = urlObj.toString();
console.log("[Preview Helper] Patched XMLHttpRequest (absolute API):", url, "→", newUrl);
return originalOpen.call(this, method, newUrl, ...rest);
}
} catch (e) {
console.error("[Preview Helper] Error processing URL:", e);
}
}
// Case 4: Handle absolute URLs to the same domain (non-API)
else if (url.includes(window.location.host)) {
try {
const urlObj = new URL(url);
if (urlObj.hostname === window.location.hostname &&
!urlObj.pathname.startsWith('/' + previewGuid + '/')) {
const path = urlObj.pathname;
const newPath = '/' + previewGuid + path;
urlObj.pathname = newPath;
const newUrl = urlObj.toString();
console.log("[Preview Helper] Patched XMLHttpRequest (absolute):", url, "→", newUrl);
return originalOpen.call(this, method, newUrl, ...rest);
}
} catch (e) {
console.error("[Preview Helper] Error processing URL:", e);
}
}
}
return originalOpen.call(this, method, url, ...rest);
};
console.log("[Preview Helper] Patched XMLHttpRequest");
// Note: Link click handling is done by the existing gallery link interceptor
// The fetch and XMLHttpRequest patches above handle programmatic requests
// Add debugging aid
window.debugPreviewUrls = function() {
return {
hostname: window.location.hostname,
pathname: window.location.pathname,
guid: window.previewGuid,
isPreviewMode: window.isPreviewMode,
sampleTransformations: {
relativeApi: window.getApiUrl('/api/menu-styles'),
relativeLink: window.getPreviewUrl('/gallery'),
absolute: new URL('/api/save-config', window.location.origin).toString().replace('/api/', '/' + previewGuid + '/api/')
}
};
};
// Test the transformation immediately
console.log("[Preview Helper] Test API transformation:", window.getApiUrl('/api/menu-styles'));
console.log("[Preview Helper] Test relative link transformation:", window.getPreviewUrl('/gallery'));
console.log("[Preview Helper] Test absolute URL transformation:",
new URL('/api/save-config', window.location.origin).toString().replace('/api/', '/' + previewGuid + '/api/'));
}
})();
</script>
<!-- Early CDN retry helpers: MUST run before CDN script/link tags.
Changing script.src after a load error is unreliable (esp. Firefox defer);
always insert a new element pointing at /cdn-proxy/. -->
<script>
(function() {
window.useCdnProxy = window.useCdnProxy || false;
window.__hydraCdnRetryLog = window.__hydraCdnRetryLog || [];
function toProxy(url) {
if (!url || url.indexOf('cdn.neonsky.app') === -1) return url;
return url.replace('https://cdn.neonsky.app/', window.location.origin + '/cdn-proxy/');
}
function noteRetry(kind, fromUrl, toUrl) {
try {
window.useCdnProxy = true;
var entry = { kind: kind, from: fromUrl, to: toUrl, t: Date.now() };
window.__hydraCdnRetryLog.push(entry);
console.warn('🔄 CDN failed, retrying via new element:', kind, fromUrl, '->', toUrl);
} catch (e) {}
}
window.__hydraRetryCdnScript = function(el) {
try {
if (!el || el.getAttribute('data-retried') === 'true') return;
var original = el.src || '';
if (original.indexOf('cdn.neonsky.app') === -1) return;
el.setAttribute('data-retried', 'true');
el.onerror = null;
var proxyUrl = toProxy(original);
noteRetry('script', original, proxyUrl);
var s = document.createElement('script');
s.src = proxyUrl;
s.setAttribute('data-retried', 'true');
if (el.id) s.id = el.id;
// Dynamic scripts ignore defer; omit it so the proxy copy executes on load.
if (el.parentNode) {
el.parentNode.insertBefore(s, el.nextSibling);
} else {
(document.head || document.documentElement).appendChild(s);
}
} catch (e) {}
};
window.__hydraRetryCdnLink = function(el) {
try {
if (!el || el.getAttribute('data-retried') === 'true') return;
var original = el.href || '';
if (original.indexOf('cdn.neonsky.app') === -1) return;
el.setAttribute('data-retried', 'true');
el.onerror = null;
var proxyUrl = toProxy(original);
noteRetry('link', original, proxyUrl);
var link = document.createElement('link');
link.rel = el.rel || 'stylesheet';
if (el.id) link.id = el.id;
link.href = proxyUrl;
link.setAttribute('data-retried', 'true');
if (el.parentNode) {
el.parentNode.insertBefore(link, el.nextSibling);
} else {
(document.head || document.documentElement).appendChild(link);
}
} catch (e) {}
};
})();
</script>
<link rel="stylesheet" href="https://cdn.neonsky.app/menu-styles-v260814-001.css?t=1787453514123" onerror="window.__hydraRetryCdnLink(this)">
<script src="https://assets.lemonsqueezy.com/lemon.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/magic-sdk"></script>
<script src="https://cdn.jsdelivr.net/npm/@magic-ext/auth"></script>
<script src="https://cdn.neonsky.app/style-customizer-v260814-001.js?t=1787453514123" defer onerror="window.__hydraRetryCdnScript(this)"></script>
<link rel="stylesheet" href="https://cdn.neonsky.app/palette-explorer-v260814-001.css?t=1787453514123" onerror="window.__hydraRetryCdnLink(this)">
<script src="https://cdn.neonsky.app/palette-explorer-v260814-001.js?t=1787453514123" defer onerror="window.__hydraRetryCdnScript(this)"></script>
<script src="https://cdn.neonsky.app/sidebar-manager-v260814-001.js?t=1787453514123" defer onerror="window.__hydraRetryCdnScript(this)"></script>
<script src="https://cdn.neonsky.app/image-processor-v260814-001.js?t=1787453514123" defer onerror="window.__hydraRetryCdnScript(this)"></script>
<script src="https://cdn.neonsky.app/image-uploader-v260814-001.js?t=1787453514123" defer onerror="window.__hydraRetryCdnScript(this)"></script>
<script src="https://cdn.neonsky.app/page-layout-presets-v260814-001.js?t=1787453514123" defer onerror="window.__hydraRetryCdnScript(this)"></script>
<script src="https://cdn.neonsky.app/site-graph-presets-v260814-001.js?t=1787453514123" defer onerror="window.__hydraRetryCdnScript(this)"></script>
<script src="https://cdn.neonsky.app/site-graph-sync-v260814-001.js?t=1787453514123" defer onerror="window.__hydraRetryCdnScript(this)"></script>
<script src="https://cdn.neonsky.app/page-manager-v260814-001.js?t=1787453514123" defer onerror="window.__hydraRetryCdnScript(this)"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.min.css">
<link rel="stylesheet" href="https://cdn.neonsky.app/page-styles-v260814-001.css?t=1787453514123" onerror="window.__hydraRetryCdnLink(this)">
<!-- CDN Fallback Handler for CSS and JS files -->
<script>
(function() {
// Global flag: Set to true when any CDN resource fails and uses proxy
window.useCdnProxy = window.useCdnProxy || false;
// Global base URL function: Returns proxy base if flag is set, otherwise CDN base
window.getCdnBaseUrl = function() {
return window.useCdnProxy
? window.location.origin + '/cdn-proxy/'
: 'https://cdn.neonsky.app/';
};
// Utility function to convert CDN URLs to proxy URLs
window.convertCdnToProxyUrl = function(cdnUrl) {
if (!cdnUrl || !cdnUrl.includes('cdn.neonsky.app')) return cdnUrl;
return cdnUrl.replace('https://cdn.neonsky.app/', window.location.origin + '/cdn-proxy/');
};
// Utility function to construct CDN URLs (uses proxy if flag is set)
window.buildCdnUrl = function(path) {
return window.getCdnBaseUrl() + path;
};
// Resource tracking for debugging
window._resourceTracking = window._resourceTracking || {
resources: [],
proxyFlagChanges: [],
imageLoads: [],
networkErrors: []
};
// Track when proxy flag changes - use a wrapper function
let proxyFlagChangeCount = 0;
let _useCdnProxyValue = window.useCdnProxy || false;
// Create a setter function that tracks changes
window._setUseCdnProxy = function(newValue, reason) {
const oldValue = _useCdnProxyValue;
if (oldValue !== newValue) {
proxyFlagChangeCount++;
const changeInfo = {
timestamp: new Date().toISOString(),
changeNumber: proxyFlagChangeCount,
oldValue: oldValue,
newValue: newValue,
reason: reason || 'Unknown',
stack: new Error().stack?.split('\n').slice(1, 6).join('\n') || 'No stack'
};
window._resourceTracking.proxyFlagChanges.push(changeInfo);
console.error('🔍 PROXY_FLAG_CHANGE:', JSON.stringify(changeInfo, null, 2));
// Keep only last 50 changes
if (window._resourceTracking.proxyFlagChanges.length > 50) {
window._resourceTracking.proxyFlagChanges.shift();
}
_useCdnProxyValue = newValue;
window.useCdnProxy = newValue;
}
};
// Track resource loading
function trackResource(url, type, status, details) {
const entry = {
timestamp: new Date().toISOString(),
url: url,
type: type, // 'script', 'link', 'image', etc.
status: status, // 'loading', 'success', 'failed', 'proxy', 'storage'
details: details || {},
useCdnProxy: window.useCdnProxy
};
window._resourceTracking.resources.push(entry);
console.error('📊 RESOURCE_TRACK:', JSON.stringify(entry, null, 2));
// Keep only last 200 resources
if (window._resourceTracking.resources.length > 200) {
window._resourceTracking.resources.shift();
}
}
// Track image loads
function trackImageLoad(url, status, source) {
const entry = {
timestamp: new Date().toISOString(),
url: url,
status: status, // 'loading', 'success', 'failed'
source: source, // 'cdn', 'proxy', 'storage'
useCdnProxy: window.useCdnProxy
};
window._resourceTracking.imageLoads.push(entry);
console.error('🖼️ IMAGE_LOAD:', JSON.stringify(entry, null, 2));
// Keep only last 100 image loads
if (window._resourceTracking.imageLoads.length > 100) {
window._resourceTracking.imageLoads.shift();
}
}
// Track network errors
function trackNetworkError(url, error, context) {
const entry = {
timestamp: new Date().toISOString(),
url: url,
error: {
message: error?.message || 'Unknown error',
name: error?.name || 'Unknown',
type: error?.type || 'unknown',
toString: String(error)
},
context: context || {},
useCdnProxy: window.useCdnProxy
};
window._resourceTracking.networkErrors.push(entry);
console.error('❌ NETWORK_ERROR:', JSON.stringify(entry, null, 2));
// Keep only last 100 errors
if (window._resourceTracking.networkErrors.length > 100) {
window._resourceTracking.networkErrors.shift();
}
}
// Expose tracking functions globally
window.trackResource = trackResource;
window.trackImageLoad = trackImageLoad;
window.trackNetworkError = trackNetworkError;
// Function to retry failed resources via proxy
// For ISP-affected clients, we assume CDN failures are network/SSL errors and set the proxy flag
// The proxy can handle both network errors and 404s, so it's safer to err on the side of using it
function retryViaProxy(element, originalSrc, errorEvent) {
if (!originalSrc) originalSrc = element.href || element.src;
if (!originalSrc || !originalSrc.includes('cdn.neonsky.app')) return;
// Prevent multiple retries
if (element.getAttribute('data-retried')) {
return;
}
element.setAttribute('data-retried', 'true');
// Check if element still exists in DOM
if (!element.parentNode) {
console.warn('⚠️ Element no longer in DOM, skipping proxy retry:', originalSrc);
return;
}
// For ISP-affected clients, CDN failures are almost always network/SSL errors, not 404s
// We'll be aggressive and set the flag for any CDN failure to ensure images also use proxy
// Check error message if available, but default to assuming network error
const hasExplicitNetworkError = errorEvent && errorEvent.message && (
errorEvent.message.includes('Failed to fetch') ||
errorEvent.message.includes('SSL') ||
errorEvent.message.includes('secure connection') ||
errorEvent.message.includes('network') ||
errorEvent.message.includes('ERR_') ||
errorEvent.message.includes('SSL_ERROR')
);
// Track the error
trackNetworkError(originalSrc, errorEvent, {
elementType: element.tagName,
hasExplicitNetworkError: hasExplicitNetworkError,
errorMessage: errorEvent?.message || 'No error message',
errorName: errorEvent?.name || 'No error name'
});
// Set flag if we have explicit network error, or if errorEvent is missing/null (browser silent failure)
// This covers ISP-affected clients where SSL handshake fails before HTTP request
const reason = hasExplicitNetworkError
? 'Explicit network/SSL error detected'
: (!errorEvent || !errorEvent.message)
? 'Browser silent failure (likely SSL/network)'
: 'CDN resource failed (using proxy as fallback)';
if (hasExplicitNetworkError || !errorEvent || !errorEvent.message) {
window._setUseCdnProxy(true, reason);
console.error('🚩 CDN_PROXY_FLAG SET: CDN failure detected, using proxy for all subsequent requests');
console.error(' Error details:', errorEvent ? (errorEvent.message || 'No error message (likely SSL/network failure)') : 'No error event');
} else {
// Even for other errors, set the flag to be safe - proxy can handle 404s too
window._setUseCdnProxy(true, reason);
console.error('🚩 CDN_PROXY_FLAG SET: CDN resource failed, using proxy as fallback');
}
const proxyUrl = originalSrc.replace('https://cdn.neonsky.app/', window.location.origin + '/cdn-proxy/');
console.error('🔄 CDN failed, retrying via proxy:', originalSrc, '->', proxyUrl);
// Track the retry
trackResource(originalSrc, element.tagName.toLowerCase(), 'failed', {
retryUrl: proxyUrl,
error: errorEvent?.message || 'Unknown'
});
if (element.tagName === 'SCRIPT') {
const newScript = document.createElement('script');
newScript.src = proxyUrl;
// Do not copy defer/async onto dynamically inserted scripts. After a CDN
// failure, DOMContentLoaded may already have fired; deferred dynamic
// scripts can fail to execute (seen on Firefox ISP/CDN-fail clients).
newScript.setAttribute('data-retried', 'true');
newScript.onload = function() {
trackResource(proxyUrl, 'script', 'proxy-success', { originalUrl: originalSrc });
};
newScript.onerror = function() {
trackResource(proxyUrl, 'script', 'proxy-failed', { originalUrl: originalSrc });
console.error('❌ Proxy also failed, trying storage:', proxyUrl);
const storageUrl = originalSrc.replace('cdn.neonsky.app', 'storage.neonsky.app');
trackResource(storageUrl, 'script', 'storage-fallback', { originalUrl: originalSrc, proxyUrl: proxyUrl });
newScript.src = storageUrl;
newScript.onload = function() {
trackResource(storageUrl, 'script', 'storage-success', { originalUrl: originalSrc });
};
};
element.parentNode.replaceChild(newScript, element);
} else if (element.tagName === 'LINK') {
const newLink = document.createElement('link');
newLink.rel = element.rel;
newLink.href = proxyUrl;
newLink.setAttribute('data-retried', 'true');
newLink.onload = function() {
trackResource(proxyUrl, 'link', 'proxy-success', { originalUrl: originalSrc });
};
newLink.onerror = function() {
trackResource(proxyUrl, 'link', 'proxy-failed', { originalUrl: originalSrc });
console.error('❌ Proxy also failed, trying storage:', proxyUrl);
const storageUrl = originalSrc.replace('cdn.neonsky.app', 'storage.neonsky.app');
trackResource(storageUrl, 'link', 'storage-fallback', { originalUrl: originalSrc, proxyUrl: proxyUrl });
newLink.href = storageUrl;
newLink.onload = function() {
trackResource(storageUrl, 'link', 'storage-success', { originalUrl: originalSrc });
};
};
element.parentNode.replaceChild(newLink, element);
}
}
// Monitor script and link tags for errors
document.addEventListener('error', function(e) {
const target = e.target;
if ((target.tagName === 'SCRIPT' || target.tagName === 'LINK') &&
target.src && target.src.includes('cdn.neonsky.app')) {
retryViaProxy(target, target.src, e);
} else if (target.tagName === 'LINK' && target.href && target.href.includes('cdn.neonsky.app')) {
retryViaProxy(target, target.href, e);
}
}, true);
// Also check existing scripts/links that may have failed before this script loaded
// For ISP-affected clients, we assume CDN failures are network/SSL errors
setTimeout(function() {
document.querySelectorAll('script[src*="cdn.neonsky.app"], link[href*="cdn.neonsky.app"]').forEach(function(el) {
if (el.tagName === 'SCRIPT' && !el.getAttribute('data-loaded') && !el.getAttribute('data-retried')) {
el.addEventListener('error', function(errorEvent) {
// For ISP-affected clients, assume any CDN failure is a network/SSL error
// The proxy can handle both network errors and 404s
retryViaProxy(el, el.src, errorEvent);
});
} else if (el.tagName === 'LINK' && !el.sheet && !el.getAttribute('data-loaded') && !el.getAttribute('data-retried')) {
// CSS link failed if it has no stylesheet - check after a delay
setTimeout(function() {
if (!el.sheet && !el.getAttribute('data-retried')) {
// For ISP-affected clients, assume any CDN failure is a network/SSL error
retryViaProxy(el, el.href, null);
}
}, 2000);
}
});
}, 100);
})();
</script>
<!-- Log Capture System - S+J to download logs (works even if gallery doesn't load) -->
<script>
(function() {
'use strict';
// Only initialize if not already initialized
if (window._indexLogCaptureInitialized) {
return;
}
window._indexLogCaptureInitialized = true;
// Wait for DOM to be ready
function initLogCapture() {
// Store original console methods
const originalConsole = {
log: console.log,
warn: console.warn,
error: console.error,
info: console.info
};
// Array to store all console messages
window._capturedLogs = window._capturedLogs || [];
// Override console methods to capture logs
['log', 'warn', 'error', 'info'].forEach(method => {
console[method] = function(...args) {
// Call original method
originalConsole[method].apply(console, args);
// Capture the log with timestamp
window._capturedLogs.push({
timestamp: new Date().toISOString(),
level: method.toUpperCase(),
message: args.map(function(arg) {
if (arg !== null && arg !== undefined && Object(arg) === arg) {
try {
return JSON.stringify(arg, null, 2);
} catch (e) {
return String(arg);
}
}
return String(arg);
}).join(' ')
});
// Keep only last 2000 logs to prevent memory issues
if (window._capturedLogs.length > 2000) {
window._capturedLogs.shift();
}
};
});
// Add keyboard shortcut to download logs (S+J)
let pressedKeys = new Set();
document.addEventListener('keydown', function(event) {
const key = (event.key || '').toLowerCase();
if (key === 's' || key === 'j') {
pressedKeys.add(key);
// Check if both S and J are pressed
if (pressedKeys.has('s') && pressedKeys.has('j')) {
event.preventDefault();
console.log('S+J detected - downloading logs...');
// Show a brief visual indicator
const indicator = document.createElement('div');
indicator.style.cssText =
'position: fixed; top: 20px; right: 20px; z-index: 10000; ' +
'background: #4CAF50; color: white; padding: 10px 20px; ' +
'border-radius: 5px; font-family: Arial, sans-serif; ' +
'font-size: 14px; font-weight: bold; ' +
'box-shadow: 0 2px 10px rgba(0,0,0,0.3);';
indicator.textContent = '📥 Downloading logs...';
document.body.appendChild(indicator);
setTimeout(() => {
if (indicator.parentNode) {
indicator.parentNode.removeChild(indicator);
}
}, 2000);
downloadLogs();
// Clear the set after triggering
pressedKeys.clear();
}
}
});
document.addEventListener('keyup', function(event) {
const key = (event.key || '').toLowerCase();
if (key === 's' || key === 'j') {
pressedKeys.delete(key);
}
});
console.log('📋 Log capture system initialized. Press S+J simultaneously to download logs.');
}
// Download captured logs as a text file
function downloadLogs() {
try {
// Get all scripts and links, categorized
const allScripts = Array.from(document.querySelectorAll('script[src]')).map(s => s.src);
const allLinks = Array.from(document.querySelectorAll('link[href]')).map(l => l.href);
// Get additional debug info
const debugInfo = {
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: new Date().toISOString(),
useCdnProxy: window.useCdnProxy || false,
windowLocation: window.location.toString(),
documentReadyState: document.readyState,
// All scripts/links (not just CDN)
allScriptsLoaded: allScripts,
allLinksLoaded: allLinks,
// CDN-specific (for backward compatibility and CDN debugging)
scriptsLoaded: allScripts.filter(src => src.includes('cdn.neonsky.app')),
linksLoaded: allLinks.filter(href => href.includes('cdn.neonsky.app')),
// Gallery scripts (loaded from Worker)
galleryScriptsLoaded: allScripts.filter(src => src.includes('/gallery-scripts/')),
galleryLinksLoaded: allLinks.filter(href => href.includes('/gallery-scripts/')),
// Failed resources
failedScripts: Array.from(document.querySelectorAll('script[src*="cdn.neonsky.app"][data-retried]')).map(s => s.src),
failedLinks: Array.from(document.querySelectorAll('link[href*="cdn.neonsky.app"][data-retried]')).map(l => l.href),
// Proxy resources
proxyScriptsLoaded: allScripts.filter(src => src.includes('/cdn-proxy/')),
proxyLinksLoaded: allLinks.filter(href => href.includes('/cdn-proxy/')),
// Resource tracking data
resourceTracking: window._resourceTracking || {
resources: [],
proxyFlagChanges: [],
imageLoads: [],
networkErrors: []
},
// Performance timing
performanceTiming: performance.timing ? {
navigationStart: performance.timing.navigationStart,
domContentLoaded: performance.timing.domContentLoadedEventEnd - performance.timing.navigationStart,
loadComplete: performance.timing.loadEventEnd - performance.timing.navigationStart,
domInteractive: performance.timing.domInteractive - performance.timing.navigationStart
} : null,
// Style / logo diagnostics (layout/color bug triage)
styleDiagnostics: (function() {
try {
var rootStyle = getComputedStyle(document.documentElement);
var logoNodes = Array.from(document.querySelectorAll('.sidebar img, .sidebar-element img, img[src*="robert-vizzini"], .horizontal-header-logo img'));
return {
siteBackgroundColor: rootStyle.getPropertyValue('--site-background-color').trim(),
menuBackgroundColor: rootStyle.getPropertyValue('--menu-background-color').trim(),
menuColor: rootStyle.getPropertyValue('--menu-color').trim(),
inlineSiteBg: document.documentElement.style.getPropertyValue('--site-background-color'),
inlineMenuBg: document.documentElement.style.getPropertyValue('--menu-background-color'),
hasMenuStyleCustomizer: !!window.MenuStyleCustomizer,
menuStyleCustomizerInitialized: !!(window.MenuStyleCustomizer && window.MenuStyleCustomizer._initialized),
menuStyleSettingsBg: window.MenuStyleCustomizer && window.MenuStyleCustomizer.settings
? {
siteBackgroundColor: window.MenuStyleCustomizer.settings.siteBackgroundColor,
menuBackgroundColor: window.MenuStyleCustomizer.settings.menuBackgroundColor,
color: window.MenuStyleCustomizer.settings.color
}
: null,
hasSidebarManager: !!window.SidebarManager,
sidebarElementCount: window.SidebarManager && window.SidebarManager.elements
? window.SidebarManager.elements.length
: 0,
hasPageManager: !!window.PageManager,
pageManagerHasLoadPage: !!(window.PageManager && typeof window.PageManager.loadPage === 'function'),
pageElementCount: (function() {
try {
return document.querySelectorAll('.gallery-container .page-element, .page-container .page-element').length;
} catch (e) { return -1; }
})(),
pageContainerHtmlLength: (function() {
try {
var pc = document.querySelector('.gallery-container .page-container, .page-container');
return pc ? (pc.innerHTML || '').length : 0;
} catch (e) { return -1; }
})(),
activeContentType: window._activeContentType || null,
logoImageCount: logoNodes.length,
logoImages: logoNodes.slice(0, 5).map(function(img) {
return {
src: img.currentSrc || img.src || '',
naturalWidth: img.naturalWidth || 0,
display: getComputedStyle(img).display,
complete: !!img.complete
};
}),
earlyCdnRetries: window.__hydraCdnRetryLog || []
};
} catch (e) {
return { error: String(e && e.message ? e.message : e) };
}
})()
};
// Create log content
const logContent = [
'=== INDEX.TS DEBUG LOGS ===',
'Generated: ' + debugInfo.timestamp,
'URL: ' + debugInfo.url,
'User Agent: ' + debugInfo.userAgent,
'useCdnProxy Flag: ' + debugInfo.useCdnProxy,
'Document Ready State: ' + debugInfo.documentReadyState,
'================================',
'TIP: Search for [AUTH_SAVE] in CONSOLE LOGS below for authentication/save failure details.',
'TIP: Search STYLE DIAGNOSTICS for layout/color/logo triage (backgrounds, MenuStyleCustomizer, SidebarManager, logo).',
'================================',
'',
'=== DEBUG INFO ===',
JSON.stringify(debugInfo, null, 2),
'',
'=== STYLE DIAGNOSTICS ===',
JSON.stringify(debugInfo.styleDiagnostics, null, 2),
'',
'=== RESOURCE TRACKING SUMMARY ===',
'Total Resources Tracked: ' + (debugInfo.resourceTracking.resources.length || 0),
'Proxy Flag Changes: ' + (debugInfo.resourceTracking.proxyFlagChanges.length || 0),
'Image Loads Tracked: ' + (debugInfo.resourceTracking.imageLoads.length || 0),
'Network Errors Tracked: ' + (debugInfo.resourceTracking.networkErrors.length || 0),
'',
'=== PROXY FLAG CHANGES ===',
JSON.stringify(debugInfo.resourceTracking.proxyFlagChanges, null, 2),
'',
'=== RESOURCE LOADING TRACKING ===',
JSON.stringify(debugInfo.resourceTracking.resources, null, 2),
'',
'=== IMAGE LOADING TRACKING ===',
JSON.stringify(debugInfo.resourceTracking.imageLoads, null, 2),
'',
'=== NETWORK ERRORS ===',
JSON.stringify(debugInfo.resourceTracking.networkErrors, null, 2),
'',
'=== CONSOLE LOGS ===',
...(window._capturedLogs || []).map(log =>
'[' + log.timestamp + '] ' + log.level + ': ' + log.message
)
].join('\n');
// Create and download file
const blob = new Blob([logContent], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'index-debug-logs-' + new Date().toISOString().replace(/[:.]/g, '-') + '.txt';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
console.log('✅ Debug logs downloaded successfully!');
} catch (error) {
console.error('❌ Failed to download logs:', error);
alert('Failed to download logs. Error: ' + (error.message || 'Unknown error'));
}
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initLogCapture);
} else {
// DOM already ready, initialize immediately
setTimeout(initLogCapture, 100);
}
})();
</script>
<!-- Add auth token if available -->
<style>
/* Override Pico.css body padding to prevent jumping between pages */
body {
padding: 0 !important;
margin: 0 !important;
display: block !important;
}
/* Scope Pico.css to only affect elements in edit mode */
:root:not([data-theme="pico"]) {
--pico-applied: dark;
}
/* Add data-theme="pico" to body when in edit mode */
body.edit-mode-active {
data-theme: dark;
}
/* Custom styles to ensure Pico only affects editor UI, not content */
.sidebar.editing button,
.edit-controls button,
.element-header button,
.text-editor button,
.image-uploader button {
--pico-applied: dark;
}
/* Hide default browser-styling when Pico is active */
[data-theme="pico"] button.btn {
appearance: dark;
-webkit-appearance: dark;
}
/* Ensure content isn't affected by Pico styles */
.gallery-container,
.page-text,
.sidebar-text {
--pico-applied: none !important;
}
/* Conflict Indicator Styles */
#conflictIndicator {
background: #d32f2f !important;
border: 1px solid #d32f2f !important;
border-radius: 4px;
padding: 8px;
cursor: pointer;
transition: opacity 0.2s;
margin-left: 8px;
display: flex; /* Use flex for centering, but hidden by default via inline style */
align-items: center;
justify-content: center;
}
#conflictIndicator:hover {
background: #e53935 !important;
border-color: #e53935 !important;
opacity: 0.9;
}
#conflictIndicator svg {
display: block;
width: 24px;
height: 24px;
margin: 0 auto;
color: white !important;
}
/* Tooltip styles for conflict indicator - allow wrapping */
#tooltip-container .tooltip[data-tooltip-for="conflictIndicator"],
#tooltip-container .tooltip {
max-width: 200px;
word-wrap: break-word;
white-space: normal;
line-height: 1.4;
}
.global-header-controls {
display: flex;
align-items: center;
gap: 8px;
}
.sidebar-header {
display: flex;
align-items: center;
gap: 8px;
}
/* Cookie Notice — paper UI (public-safe; does not depend on edit-mode pe-* scopes) */
.cookie-notice {
--cn-paper: #f6f5f1;
--cn-paper-elevated: #fdfcfa;
--cn-ink: #2c2a27;
--cn-ink-muted: #6b6760;
--cn-ink-faint: #9b968d;
--cn-rule: rgba(44, 42, 39, 0.1);
--cn-rule-strong: rgba(44, 42, 39, 0.16);
--cn-shadow: 0 -8px 28px rgba(44, 42, 39, 0.1);
--cn-radius: 3px;
--cn-font: "DM Sans", system-ui, -apple-system, sans-serif;
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.85rem 1.25rem;
padding: 1.1rem 1.35rem 1.2rem;
font-family: var(--cn-font);
font-size: 14px;
line-height: 1.45;
color: var(--cn-ink);
background: var(--cn-paper-elevated);
border-top: 1px solid var(--cn-rule);
box-shadow: var(--cn-shadow);
}
.cookie-notice.hidden {
display: none;
}
.cookie-notice-text {
flex: 1 1 220px;
min-width: 0;
margin: 0;
color: var(--cn-ink-muted);
font-size: 13px;
}
.cookie-notice-text a.cookie-notice-policy {
color: var(--cn-ink);
font-weight: 600;
text-decoration: underline;
text-underline-offset: 2px;
}
.cookie-notice-text a.cookie-notice-policy:hover {
color: var(--cn-ink-muted);
}
.cookie-notice-buttons {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
}
.cookie-notice .pe-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
margin: 0;
padding: 0.45rem 0.85rem;
font-family: var(--cn-font);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--cn-ink-muted);
background: var(--cn-paper);
border: 1px solid var(--cn-rule-strong);
border-radius: var(--cn-radius);
box-shadow: none;
cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
}
.cookie-notice .pe-btn:hover {
color: var(--cn-ink);
background: var(--cn-paper-elevated);
border-color: rgba(44, 42, 39, 0.22);
}
.cookie-notice .pe-btn--primary {
color: var(--cn-ink);
background: var(--cn-paper);
border-color: var(--cn-rule-strong);
}
.cookie-notice .pe-btn--primary:hover {
color: var(--cn-ink);
background: var(--cn-paper-elevated);
border-color: rgba(44, 42, 39, 0.22);
}
#hydraDoNotSellLink {
--cn-paper: #f6f5f1;
--cn-paper-elevated: #fdfcfa;
--cn-ink: #2c2a27;
--cn-ink-muted: #6b6760;
--cn-rule-strong: rgba(44, 42, 39, 0.16);
--cn-radius: 3px;
--cn-font: "DM Sans", system-ui, -apple-system, sans-serif;
position: fixed;
bottom: 10px;
left: 12px;
z-index: 9999;
display: inline-flex;
align-items: center;
justify-content: center;
margin: 0;
padding: 0.45rem 0.85rem;
font-family: var(--cn-font);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--cn-ink-muted);
background: var(--cn-paper-elevated);
border: 1px solid var(--cn-rule-strong);
border-radius: var(--cn-radius);
box-shadow: 0 2px 8px rgba(44, 42, 39, 0.06);
cursor: pointer;
}
#hydraDoNotSellLink.hidden {
display: none;
}
#hydraDoNotSellLink:hover {
color: var(--cn-ink);
background: var(--cn-paper);
border-color: rgba(44, 42, 39, 0.22);
}
/* FOUC reveal (paired with early <head> html:not(.hydra-ready) { opacity: 0 }) */
html.hydra-ready {
opacity: 1;
transition: opacity 0.2s ease;
}
/* Keep body class for any legacy selectors; visibility no longer used (submenu CSS pierces it) */
body.hydra-ready {
visibility: visible;
}
/* Admin panel typography */
.sidebar-element-form,
.sidebar-element-form *,
.add-form,
.add-form *,
.metadata-editor,
.metadata-editor * {
font-family: "Hanken Grotesk", sans-serif;
font-weight: 200;
letter-spacing: 0.02em;
}
.sidebar-element-form h3,
.sidebar-element-form label,
.add-form label,
.metadata-editor h3,
.metadata-editor label {
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 300;
}
.sidebar-element-form .form-actions .btn,
.sidebar-element-form button,
.add-form .form-actions .btn,
.add-form button,
.metadata-editor .form-actions .btn,
.metadata-editor button {
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 300;
}
.sidebar-element-form input,
.sidebar-element-form select,
.sidebar-element-form textarea,
.sidebar-element-form button,
.add-form input,
.add-form select,
.add-form textarea,
.add-form button,
.metadata-editor input,
.metadata-editor select,
.metadata-editor textarea,
.metadata-editor button {
font-family: "Hanken Grotesk", sans-serif;
font-weight: 200;
letter-spacing: 0.02em;
border-radius: 0;
}
</style>
<script>
// Flag to prevent duplicate initialization
window.hydraInitialized = false;
function ensureAdminPanelFont() {
const head = document.head || document.getElementsByTagName('head')[0];
if (!head) {
return;
}
const linkDefinitions = [
{
id: 'admin-panels-font-preconnect-googleapis',
rel: 'preconnect',
href: 'https://fonts.googleapis.com'
},
{
id: 'admin-panels-font-preconnect-gstatic',
rel: 'preconnect',
href: 'https://fonts.gstatic.com',
crossOrigin: 'anonymous'
},
{
id: 'admin-panels-font-stylesheet',
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=Hanken+Grotesk:ital,wght@0,200;1,200&display=swap'
},
{
id: 'admin-panels-font-barlow',
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,200;0,400;1,200;1,400&family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap'
}
];
for (const definition of linkDefinitions) {
if (!document.getElementById(definition.id)) {
const link = document.createElement('link');
link.id = definition.id;
link.rel = definition.rel;
link.href = definition.href;
if (definition.crossOrigin) {
link.crossOrigin = definition.crossOrigin;
}
head.appendChild(link);
}
}
}
ensureAdminPanelFont();
window.Parameters = window.Parameters || {};
window.Parameters.siteId = "y260f4a7";
window.Parameters.SiteId = "y260f4a7"; // Also set capitalized version
window.siteId = "y260f4a7";
window.TURNSTILE_SITE_KEY = "0x4AAAAAADyHXlv6RTUIaDOp";
window.NEON_SKY_FILES = window.NEON_SKY_FILES || {};
window.NEON_SKY_FILES.QUILL_INTEGRATION = 'quill-integration-v260814-001.js';
window.NEON_SKY_FILES.SITE_THEMES = 'site-themes-v260814-001.css';
window.NEON_SKY_FILES.SITE_THEMES_MANIFEST = 'site-themes-v260814-001.json';
// Initialize menu styles from server
document.addEventListener('DOMContentLoaded', function() {
// Apply initial menu styles from server
const initialMenuStyles = {"fontFamily":"'Courier New', monospace","fontSize":"13px","color":"#424242","activeColor":"#1a1a1a","hoverColor":"#000000","verticalSpacing":"2px","menuBackgroundColor":"#ffffff","menuBackgroundHidden":false,"siteBackgroundColor":"#ffffff","siteBackgroundUseGradient":false,"siteGradientRandomizeOnLoad":false,"artPaletteSelection":null,"menuBackgroundImageUrl":"","siteBackgroundImageUrl":"","mobileHeaderHeight":"60px","sidebarWidth":"15vw","sidebarPaddingTop":"0px","sidebarPaddingRight":"0px","sidebarPaddingBottom":"0px","sidebarPaddingLeft":"0px","menuLayout":"sidebar","horizontalMenuAlignment":"left","horizontalHeaderLogoLayout":"inline","horizontalHeaderShadow":true,"disableRightClick":false,"showCookieNotice":false,"cookieNoticeText":"This website uses cookies to ensure you get the best experience on our website.","alignElementsAtBottom":true,"customCSS":"","customJS":"","siteThemeId":null,"siteThemeOptions":{},"fontName":"Courier New"};
window.__hydraStyleFromQuery = null;
const initialSiteThemeRuntimeVars = null;
// Set CSS custom properties
// ?hydra-style= preview: apply merged menuStyles (skip site-theme runtime vars that would override).
if (initialSiteThemeRuntimeVars && !window.__hydraStyleFromQuery) {
Object.keys(initialSiteThemeRuntimeVars).forEach(function(key) {
document.documentElement.style.setProperty(key, initialSiteThemeRuntimeVars[key]);
});
if (initialMenuStyles && initialMenuStyles.verticalSpacing) {
document.documentElement.style.setProperty('--menu-vertical-spacing', initialMenuStyles.verticalSpacing);
}
// Match menu chrome / Gallery Options overview preview (light weight)
document.documentElement.style.setProperty('--menu-font-weight', '200');
if (initialMenuStyles && typeof initialMenuStyles.secondaryFontSize === 'string' && initialMenuStyles.secondaryFontSize.trim() !== '') {
document.documentElement.style.setProperty('--gallery-description-font-size', initialMenuStyles.secondaryFontSize.trim());
}
} else if (initialMenuStyles) {
if (initialMenuStyles.fontFamily) {
document.documentElement.style.setProperty('--menu-font-family', initialMenuStyles.fontFamily);
}
if (initialMenuStyles.fontSize) {
document.documentElement.style.setProperty('--menu-font-size', initialMenuStyles.fontSize);
}
// Only set when Secondary was explicitly saved; otherwise descriptions follow --menu-font-size
if (typeof initialMenuStyles.secondaryFontSize === 'string' && initialMenuStyles.secondaryFontSize.trim() !== '') {
document.documentElement.style.setProperty('--gallery-description-font-size', initialMenuStyles.secondaryFontSize.trim());
}
document.documentElement.style.setProperty('--menu-font-weight', '200');
if (initialMenuStyles.color) {
document.documentElement.style.setProperty('--menu-color', initialMenuStyles.color);
}
if (initialMenuStyles.activeColor) {
document.documentElement.style.setProperty('--menu-active-color', initialMenuStyles.activeColor);
}
if (initialMenuStyles.hoverColor) {
document.documentElement.style.setProperty('--menu-hover-color', initialMenuStyles.hoverColor);
}
// Background colors must be applied here too: MenuStyleCustomizer may load late/fail
// after CDN→proxy retry, and CSS defaults are white/#fafafa.
if (initialMenuStyles.siteBackgroundColor) {
document.documentElement.style.setProperty('--site-background-color', initialMenuStyles.siteBackgroundColor);
}
if (initialMenuStyles.menuBackgroundColor && !initialMenuStyles.menuBackgroundHidden) {
document.documentElement.style.setProperty('--menu-background-color', initialMenuStyles.menuBackgroundColor);
}
if (initialMenuStyles.verticalSpacing) {
document.documentElement.style.setProperty('--menu-vertical-spacing', initialMenuStyles.verticalSpacing);
}
if (initialMenuStyles.menuBackgroundImageUrl && String(initialMenuStyles.menuBackgroundImageUrl).trim() !== '') {
var menuBgImg = String(initialMenuStyles.menuBackgroundImageUrl).trim();
document.documentElement.style.setProperty('--menu-background-image', 'url(' + JSON.stringify(menuBgImg) + ')');
}
var resolvedSiteBg = '';
if (window.SiteGradientGenerator && typeof window.SiteGradientGenerator.resolveSiteBackgroundImage === 'function') {
resolvedSiteBg = window.SiteGradientGenerator.resolveSiteBackgroundImage(initialMenuStyles, { allowRandomize: true });
} else if (initialMenuStyles.siteBackgroundImageUrl) {
resolvedSiteBg = String(initialMenuStyles.siteBackgroundImageUrl).trim();
}
if (resolvedSiteBg) {
var peIsCssGradient = String(resolvedSiteBg).toLowerCase().indexOf('gradient(') !== -1;
var siteBgImgCss = peIsCssGradient
? resolvedSiteBg
: 'url(' + JSON.stringify(resolvedSiteBg) + ')';
document.documentElement.style.setProperty('--site-background-image', siteBgImgCss);
var siteBgSize = 'cover';
if (peIsCssGradient) {
siteBgSize = window.SiteGradientGenerator && window.SiteGradientGenerator.gradientBackgroundSize
? window.SiteGradientGenerator.gradientBackgroundSize(resolvedSiteBg)
: '100% 100%';
document.body.classList.add('has-site-background-gradient');
}
document.documentElement.style.setProperty('--site-background-size', siteBgSize);
}
if (initialMenuStyles.mobileHeaderHeight && String(initialMenuStyles.mobileHeaderHeight).trim() !== '') {
var mhh0 = String(initialMenuStyles.mobileHeaderHeight).trim();
/* 0 / 0vh / 0px collapse logo max-height calcs; use the same floor as getUserHorizontalHeaderHeightPx */
var mhhParsed = parseInt(mhh0, 10);
if (Number.isNaN(mhhParsed) || mhhParsed < 1) {
mhh0 = '60px';
}
document.documentElement.style.setProperty('--mobile-header-height', mhh0);
document.documentElement.style.setProperty('--content-margin-top', mhh0);
document.documentElement.style.setProperty('--top-bar-height-setting', mhh0);
}
}
if (initialMenuStyles && initialMenuStyles.menuBackgroundHidden) {
document.body.classList.add('menu-background-hidden');
document.documentElement.style.setProperty('--menu-background-color', 'transparent');
document.documentElement.style.setProperty('--menu-background-image', 'none');
}
if (initialMenuStyles && initialMenuStyles.menuTextUppercase) {
document.body.classList.add('menu-text-uppercase');
if (!document.getElementById('menu-text-uppercase-style')) {
var menuUpperStyle = document.createElement('style');
menuUpperStyle.id = 'menu-text-uppercase-style';
menuUpperStyle.textContent = '.menu-item,.horizontal-menu-label,a.horizontal-menu-label{text-transform:uppercase!important;font-weight:200!important;}';
document.head.appendChild(menuUpperStyle);
}
}
// Store initial styles in MenuStyleCustomizer if it exists
if (window.MenuStyleCustomizer) {
window.MenuStyleCustomizer.settings = {
...window.MenuStyleCustomizer.defaults,
...initialMenuStyles
};
}
});
</script>
<script>
let magic = null;
let userMetadata = null;
let isAuthenticated = false;
let isAdmin = false;
let didToken = null;
let siteId = "y260f4a7";
/** Clear Hydra auth tokens/flags from this browser (used by logout button and ?logout=1 links). */
function clearHydraClientAuthStorage() {
try {
localStorage.removeItem('hydra_is_admin');
localStorage.removeItem('hydra_auth_token');
localStorage.removeItem('hydra_auth_email');
localStorage.removeItem('hydra_did_token');
localStorage.removeItem('hydra_auth_site');
localStorage.removeItem('hydra_auth_last_refresh');
localStorage.removeItem('hydra_auth_auto_refresh');
} catch (e) {}
if (window.TokenManager && typeof window.TokenManager.clearToken === 'function') {
try { window.TokenManager.clearToken(); } catch (e) {}
}
isAuthenticated = false;
isAdmin = false;
didToken = null;
userMetadata = null;
window.isAuthenticated = false;
window.isAdmin = false;
window.didToken = null;
}
function hydraUrlRequestsLogout() {
try {
var params = new URLSearchParams(window.location.search);
var path = (window.location.pathname || '/').replace(/\/+$/, '') || '/';
if (params.get('logout') === '1' || params.get('logout') === 'true') return true;
if (params.get('hydra_logout') === '1' || params.get('hydra_logout') === 'true') return true;
return path === '/_manage/logout';
} catch (e) {
return false;
}
}
function hydraCleanLogoutFromUrl() {
try {
var params = new URLSearchParams(window.location.search);
var path = (window.location.pathname || '/').replace(/\/+$/, '') || '/';
var hadQuery = params.has('logout') || params.has('hydra_logout');
params.delete('logout');
params.delete('hydra_logout');
var nextPath = path === '/_manage/logout' ? '/_manage' : window.location.pathname;
var qs = params.toString();
var next = nextPath + (qs ? '?' + qs : '') + (window.location.hash || '');
if (hadQuery || path === '/_manage/logout') {
history.replaceState({}, '', next);
}
} catch (e) {}
}
(function hydraLogoutFromUrlEarly() {
if (!hydraUrlRequestsLogout()) return;
clearHydraClientAuthStorage();
try { sessionStorage.setItem('hydra_logout_cleared', '1'); } catch (e) {}
hydraCleanLogoutFromUrl();
console.log('[Hydra] Auth cleared via logout link/query.');
})();
function hydraUrlRequestsRefresh() {
try {
var params = new URLSearchParams(window.location.search);
var v = params.get('refresh');
return v === '1' || v === 'true';
} catch (e) {
return false;
}
}
function hydraClearGalleryClientCache() {
try {
Object.keys(localStorage).filter(function (key) {
return key.includes('gallery') || key.includes('neon') || key.includes('lightbox');
}).forEach(function (key) {
localStorage.removeItem(key);
});
} catch (e) {}
try {
delete window.__neonGalleryMainBootstrapWipeDone;
} catch (e) {
window.__neonGalleryMainBootstrapWipeDone = false;
}
window._forceGalleryReload = true;
}
function hydraCleanRefreshFromUrl() {
try {
var params = new URLSearchParams(window.location.search);
if (!params.has('refresh')) return;
params.delete('refresh');
var qs = params.toString();
var next = window.location.pathname + (qs ? '?' + qs : '') + (window.location.hash || '');
history.replaceState({}, '', next);
} catch (e) {}
}
(function hydraRefreshFromUrlEarly() {
if (!hydraUrlRequestsRefresh()) return;
clearHydraClientAuthStorage();
hydraClearGalleryClientCache();
try { sessionStorage.setItem('hydra_refresh_cleared', '1'); } catch (e) {}
hydraCleanRefreshFromUrl();
console.log('[Hydra] Gallery cache/auth cleared via ?refresh=1 — forcing fresh load.');
})();
// State Management — site-wide KV metadata for the editor (not per-page SEO)
window.siteMetadata = {"title":"","description":"","googleAnalytics":"","noIndex":false,"favicon":"","ogImageUrl":"","contentLanguage":"en","copyrightEnabled":false,"copyrightText":"","customPublicSiteEnabled":false,"customPublicSiteHtmlR2Key":"","customPublicSiteHtmlFilename":"","facebookPixel":""};
console.log('Client Initialized site metadata:', window.siteMetadata);
/** Match SSR title format: "{Gallery Title} | {Site Title}" */
function formatGalleryDocumentTitle(galleryTitle, win) {
win = win || window;
var meta = win.siteMetadata;
var siteTitle = (meta && meta.title && String(meta.title).trim()) || win.location.hostname;
return (galleryTitle || 'Gallery') + ' | ' + siteTitle;
}
console.log('Client Google Analytics from metadataToUse:', '');
console.log('Client Google Analytics in window.siteMetadata:', window.siteMetadata.googleAnalytics);
console.log('Client Content Language:', window.siteMetadata.contentLanguage);
// State Management
let galleries = [{"id":1784234354097,"title":"JAMES@JAMESPOMERANTZ.COM","url":"/james@jamespomerantz.com","isIntegrated":true,"isPage":false,"isSubmenu":false,"visible":false,"parentId":null,"siteId":"y260f4a7","pageId":"mzliruro","classicGuid":"4bd5ebf38f231","importSource":"classic-gallery","slug":"james@jamespomerantz.com","normalizedName":"james@jamespomerantz.com","galleryOptions":{"manualCollectionName":"GUID=4bd5ebf38f231","importedFromClassic":true,"siteId":"y260f4a7","pageId":"mzliruro","horizontalScrollerText":"<p><br></p>","jsonCollections":{"GUID=4bd5ebf38f231":{"isClassicCollection":true,"guid":"GUID=4bd5ebf38f231","metadata":{},"totalItems":0}},"siteAlias":"preview.neonsky.app","initialPageUuid":"mzliruro","initialPageAlias":"james@jamespomerantz.com","timestamp":1784234401301,"startInSingles":true,"layoutSelection":"singles","useRowsLayout":false,"layoutType":"grid","columns":3,"columnsGridImageMask":false,"columnsGridImageAspectRatio":"square","itemsPerRow":2,"spacing":2,"showDescriptionsInGrid":false,"useDesktopGridOnMobile":false,"useDesktopHorizontalScrollOnMobile":false,"autoplaySingles":true,"autoplayDuration":"3.5","autoplayTransition":"1.5","showDescription":false,"descriptionsOnDemand":false,"showCounter":false,"showFilename":false,"displayAllInfo":false,"thumbnailNavigation":false,"navigationMode":"dynamicCursor","showTextBlock":false,"overviewOnDemand":false,"fixedHeroImage":false,"zoomInLightbox":false,"lightboxOnMobile":false,"fadeDuration":"500","fadeInDuration":100,"descriptionTextColor":"0, 0, 0","gridImageOverlayColor":"130, 130, 130","gridImageOverlayOpacity":"0.5","lightboxBgColor":"255, 255, 255","lightboxBgOpacity":"1","lightboxCloseColor":"0, 0, 0","lightboxArrowColor":"0, 0, 0","useTitles":false,"useLinks":false,"desktopTitleDisplayMode":"overlay","titleTextAlign":"center","showDescriptionInOverlay":false,"includeRolloverImageInOverlay":true,"filterMenuEnabled":false,"filterMenuCollectionGalleryPicker":"__filter_pick_blank__","filterMenuCollectionNames":"","filterMenuTitles":"","filterMenuStyle":"dropdown","rolloverSwap":false,"rolloverCollectionNames":"","rolloverCollectionGalleryPicker":"__rollover_pick_blank__","openMultipleLightboxes":false,"batchSize":20,"applyToAllGalleriesCheckbox":false,"layout":{"type":"grid","selection":"singles","columns":3,"spacing":2,"useRowsLayout":false,"itemsPerRow":2,"useDesktopGridOnMobile":false,"useDesktopHorizontalScrollOnMobile":false,"showDescriptionsInGrid":false,"columnsGridImageMask":false,"columnsGridImageAspectRatio":"square"},"isPerma":false},"isHomePage":true,"siteAlias":null},{"id":1784234354096,"title":"Contact","isPage":true,"isIntegrated":true,"isSubmenu":false,"pageId":"page_1784234354096","visible":true,"parentId":null,"siteId":"y260f4a7","slug":"home","url":"/home","pageElements":[{"id":1784234354996,"type":"text","title":"Text Block","visible":true,"position":0,"textContent":"<p style=\"line-height: 1; margin-bottom: 0.5em;\" class=\"ql-align-center\"><span style=\"font-size: 14px; font-family: Courier-New, monospace;\"><a href=\"JAMES@JAMESPOMERANTZ.COM\" target=\"_blank\">JAMES@JAMESPOMERANTZ.COM</a></span></p>","textWidth":100,"fadeInOnScroll":false},{"id":1784234475929,"type":"metadata","visible":true,"position":1,"metaTitle":"","metaDescription":"","metaKeywords":"","pagePaddingTop":"20vh"}],"classicGuid":"4bd5ebf38f231","importSource":"classic-page","isHomePage":false,"customPageHtmlEnabled":false,"customPageHtmlR2Key":"","customPageHtmlFilename":""}];
window.galleries = galleries;
function getSiteGalleriesArray() {
if (typeof galleries !== 'undefined' && Array.isArray(galleries)) {
return galleries;
}
if (typeof window.galleries !== 'undefined' && Array.isArray(window.galleries)) {
return window.galleries;
}
if (window.GALLERY_DATA_CACHE &&
window.GALLERY_DATA_CACHE.initialized &&
Array.isArray(window.GALLERY_DATA_CACHE.galleries)) {
return window.GALLERY_DATA_CACHE.galleries;
}
return [];
}
window.getSiteGalleriesArray = getSiteGalleriesArray;
let activeGalleryId = null;
let sortableInstance = null;
let isEditing = false;
let initialPageInfo = null;
// Store the current menu layout type (must be JSON-encoded — bare sidebar/horizontal is invalid JS)
let currentMenuLayout = null;
/** True when this document is loaded in the chromeless iframe (?neon_embed=1 or body.neon-page-embed). */
function isNeonPageEmbedShell() {
try {
if (typeof document !== 'undefined' && document.body && document.body.classList.contains('neon-page-embed'))
return true;
var _spEmbed = new URLSearchParams(window.location.search);
return _spEmbed.get('neon_embed') === '1';
} catch (_eEmbed) { return false; }
}
/**
* Mirrors "hide menu on this page": in embed shells always hide menus (unless editing),
* otherwise use each gallery/page flag.
*/
function shouldApplyMenuHiddenForEmbedOrGallery(gallery) {
var editing = typeof window.isInEditMode === 'function' && window.isInEditMode();
if (editing)
return !!(gallery && gallery.hideMenuOnPage);
return !!(gallery && gallery.hideMenuOnPage) || isNeonPageEmbedShell();
}
document.addEventListener('DOMContentLoaded', () => {
if (window.hydraInitialized) {
console.log("Hydra already initialized, skipping");
return;
}
window.hydraInitialized = true;
document.body.classList.add('hydra-initialized');
try {
if (sessionStorage.getItem('hydra_refresh_cleared') === '1') {
sessionStorage.removeItem('hydra_refresh_cleared');
if (window.EnhancedGallerySystem && typeof window.EnhancedGallerySystem.resetInitializationState === 'function') {
window.EnhancedGallerySystem.resetInitializationState();
}
console.log('[Hydra] Applied ?refresh=1 — gallery framework reset for clean load.');
}
} catch (e) {}
try {
var neonEmbedParams = new URLSearchParams(window.location.search);
if (neonEmbedParams.get('neon_embed') === '1') {
document.body.classList.add('neon-page-embed', 'menu-hidden-on-page');
}
} catch (_neonEmbedErr) {}
window.Parameters = window.Parameters || {};
if (localStorage.getItem('hydra_is_admin') === 'true') {
document.body.classList.add('hydra-admin', 'hydra-authenticated');
isAdmin = true;
isAuthenticated = true;
console.log('Restored admin status from localStorage');
// CRITICAL FIX: Clear all caches except auth tokens for logged-in admins
// This ensures admins always see fresh data when opening new tabs
try {
console.log('Admin detected - clearing all caches except auth tokens...');
// Save auth tokens first
const savedAuthToken = localStorage.getItem('hydra_auth_token');
const savedAuthEmail = localStorage.getItem('hydra_auth_email');
const savedIsAdmin = localStorage.getItem('hydra_is_admin');
const savedDidToken = localStorage.getItem('hydra_did_token');
// Clear ALL localStorage
localStorage.clear();
// Restore auth tokens
if (savedAuthToken) localStorage.setItem('hydra_auth_token', savedAuthToken);
if (savedAuthEmail) localStorage.setItem('hydra_auth_email', savedAuthEmail);
if (savedIsAdmin) localStorage.setItem('hydra_is_admin', savedIsAdmin);
if (savedDidToken) localStorage.setItem('hydra_did_token', savedDidToken);
// Clear ALL sessionStorage (auth tokens are not stored here)
sessionStorage.clear();
console.log('Cleared all caches except auth tokens for admin user');
} catch (cacheError) {
console.warn('Error clearing caches on admin page load:', cacheError);
// Don't fail page load if cache clearing fails
}
}
function ensureSidebarElementsAndRender() {
console.log('Ensuring sidebar elements are available before rendering...');
// Check if SidebarManager exists and has elements
if (window.SidebarManager && (!window.SidebarManager.elements || window.SidebarManager.elements.length === 0)) {
// Try to initialize from siteConfig if available
if (typeof siteConfig !== 'undefined' && siteConfig.sitebarElements && siteConfig.sidebarElements.length > 0) {
console.log('Initializing SidebarManager elements from siteConfig');
window.SidebarManager.elements = siteConfig.sidebarElements;
}
}
// Determine layout and render
const currentMenuLayout = getEffectiveMenuLayout();
if (currentMenuLayout === 'horizontal') {
console.log('Rendering horizontal menu with ensured sidebar elements');
renderHorizontalMenu();
} else {
renderGalleries();
}
}
setTimeout(() => {
console.log("Initializing galleries:", galleries.length);
// Collapse folders before first paint so nested items never flash as expanded plain text
alignFolderStates();
// CHANGED: Use the new function instead of direct rendering
setTimeout(ensureSidebarElementsAndRender, 100); // Small delay to ensure sidebar init
initMobileMenu();
/** If true, deep-link timeouts below must not call handleURLNavigation again (duplicate loadPage / blink). */
let bootstrapRouteAttempted = false;
// Handle direct URL navigation
if (initialPageInfo) {
console.log("Loading initial page from direct URL:", initialPageInfo);
// Find the gallery by ID
const gallery = galleries.find(g => g.id === initialPageInfo.id);
if (gallery) {
// Set active gallery ID
activeGalleryId = gallery.id;
if (initialPageInfo.type === 'page') {
console.log("Loading page directly:", gallery.title);
loadPage(gallery.id);
} else {
console.log("Loading gallery directly:", gallery.title);
loadGallery(gallery.id);
}
bootstrapRouteAttempted = true;
}
} else if (window.location.pathname === '/') {
// No specific path, try to load home page
console.log("No specific path, checking for home page");
if (!loadHomePage() && galleries.length > 0) {
// Default to first gallery if no home page is set
loadGallery(galleries[0].id);
}
bootstrapRouteAttempted = true;
} else if (galleries.length > 0) {
// Path exists but no matching initialPageInfo, try to handle URL path
handleURLNavigation();
bootstrapRouteAttempted = true;
}
window.__hydraBootstrapRouteAttempted = bootstrapRouteAttempted;
// Log what was rendered
console.log("Menu content:", document.getElementById('galleryTree')?.innerHTML || "Gallery tree not found");
// Start with edit controls properly set
toggleEditControlsVisibility(isAdmin && document.body.classList.contains('edit-mode-active'));
// Explicitly hide the import classic form too
const importForm = document.getElementById('importClassicForm');
if (importForm) {
importForm.style.display = 'none';
importForm.classList.remove('visible');
}
// Try to restore token from localStorage
if (!didToken) {
didToken = localStorage.getItem('hydra_auth_token');
if (didToken) {
console.log('Restored auth token from localStorage');
}
}
// Initialize Magic and check authentication
// Initialize sidebar elements if available
if (window.SidebarManager) {
// Pass the initial elements from server config if available
if (true) {
window.SidebarManager.elements = [{"id":1784234095389,"type":"menu","title":"Menu","visible":true,"position":0},{"id":1784234509880,"type":"social","title":"Social","visible":true,"position":1,"socialIconSize":20,"socialIcons":[{"type":"instagram","url":"https://instagram.com/jamespomerantz"}],"socialAlignment":"left"}];
}
window.SidebarManager.init();
}
// Initialize metadata from server config (site-wide only — not current page SEO)
window.siteMetadata = {"title":"","description":"","googleAnalytics":"","noIndex":false,"favicon":"","ogImageUrl":"","contentLanguage":"en","copyrightEnabled":false,"copyrightText":"","customPublicSiteEnabled":false,"customPublicSiteHtmlR2Key":"","customPublicSiteHtmlFilename":"","facebookPixel":""};
console.log('Client Initialized site metadata (second init):', window.siteMetadata);
console.log('Client Google Analytics from metadataToUse (second init):', '');
console.log('Client Google Analytics in window.siteMetadata (second init):', window.siteMetadata.googleAnalytics);
console.log('Client Content Language (second init):', window.siteMetadata.contentLanguage);
// Update copyright footer after metadata is set (important for main domain)
if (window.SidebarManager && typeof window.SidebarManager.updateMetadataFooter === 'function') {
window.SidebarManager.updateMetadataFooter();
}
const submenuCheck = document.getElementById('createSubmenu');
const submenuTitleField = document.getElementById('submenuTitle');
const submenuTitleGroup = submenuTitleField?.parentElement;
if (submenuCheck && submenuTitleGroup) {
submenuCheck.addEventListener('change', function() {
submenuTitleGroup.style.display = this.checked ? 'block' : 'none';
});
}
// Check authentication state after initialization
if (isAuthenticated && isAdmin) {
console.log('Already authenticated and admin, showing edit UI');
forceShowEditUI();
} else if (didToken) {
console.log('Have token, checking admin status');
checkAuth();
}
}, 100); // Small delay to ensure everything is loaded
setTimeout(() => {
if (window.location.pathname === '/' ||
window.location.pathname === '/_manage' || window.location.pathname === '/_manage/' ||
(window.location.hostname === 'preview.neonsky.app' &&
window.location.pathname.split('/').length <= 2)) {
// Root URL or preview URL with only GUID - check for home page
// Skip if bootstrap already routed (themed sites boot slower and would hit a duplicate load)
if (!initialPageInfo && !window.__hydraBootstrapRouteAttempted) {
loadHomePage();
}
}
}, 500);
// Dedicated editor URL: open edit tools or OTP login without requiring + key
setTimeout(function hydraManageEditorEntry() {
var p = window.location.pathname;
if (p !== '/_manage' && p !== '/_manage/') return;
try {
if (sessionStorage.getItem('hydra_logout_cleared') === '1') {
sessionStorage.removeItem('hydra_logout_cleared');
console.log('[Hydra] Logout link cleared auth; skipping auto-login on /_manage.');
return;
}
} catch (e) {}
if (document.body.classList.contains('edit-mode-active')) return;
try {
var token = window.didToken || localStorage.getItem('hydra_auth_token');
var admin = window.isAdmin === true || localStorage.getItem('hydra_is_admin') === 'true';
if (token && admin) {
if (typeof window.toggleEditMode === 'function') window.toggleEditMode();
return;
}
if (token && typeof checkAdminStatus === 'function') {
checkAdminStatus(token).then(function() {
if (window.isAdmin && typeof window.toggleEditMode === 'function' && !document.body.classList.contains('edit-mode-active')) {
window.toggleEditMode();
}
}).catch(function() {});
return;
}
if (typeof startOTPLogin === 'function') {
var pr = startOTPLogin();
if (pr && typeof pr.catch === 'function') pr.catch(function() {});
}
} catch (e) {
console.error('hydraManageEditorEntry:', e);
}
}, 2200);
});
/**
* Accessible name for header/sidebar brand images (no visual change).
* Prefers element title or site metadata over generic "Image" / filenames.
*/
function resolveBrandLogoAlt(element) {
if (window.SidebarManager && typeof window.SidebarManager._resolveBrandImageAlt === 'function') {
return window.SidebarManager._resolveBrandImageAlt(element || {});
}
const siteTitle =
(window.siteMetadata && String(window.siteMetadata.title || '').trim()) ||
(document.title && document.title.includes('|') ? document.title.split('|').pop().trim() : '') ||
String(location.hostname || '').replace(/^www\./i, '');
const title = element && element.title ? String(element.title).trim() : '';
if (title && !/^image(\s*\d*)?$/i.test(title)) return title;
return siteTitle || 'Site logo';
}
/**
* Gets the first visible sidebar element (image or text) to be used as a logo or title.
* @returns {object|null} The first suitable sidebar element or null.
*/
function getFirstSidebarElementForHeader() {
if (window.SidebarManager && window.SidebarManager.elements && window.SidebarManager.elements.length > 0) {
const visibleElements = window.SidebarManager.elements.filter(el => el.visible !== false); // Filter 1: visible
if (visibleElements.length > 0) {
const sortedElements = visibleElements.sort((a, b) => a.position - b.position);
// Debug: Log all element types found
const elementTypes = sortedElements.map(el => ({ type: el.type, id: el.id, title: el.title }));
console.log('[getFirstSidebarElementForHeader] Visible elements found:', elementTypes);
for (let el of sortedElements) {
if (el.type === 'image' || el.type === 'text') { // Filter 2: type
console.log('[getFirstSidebarElementForHeader] Found matching element:', { type: el.type, id: el.id, title: el.title, linkType: el.linkType, linkUrl: el.linkUrl, linkPageId: el.linkPageId });
return el;
}
}
// If loop finishes, no visible image or text found among visible elements
console.log('[getFirstSidebarElementForHeader] Found visible elements, but none were type image/text. Element types found:', elementTypes.map(e => e.type));
return null;
} else {
console.log('[getFirstSidebarElementForHeader] No elements with visible !== false found in SidebarManager.elements. Total elements:', window.SidebarManager.elements.length);
return null;
}
}
console.log('[getFirstSidebarElementForHeader] SidebarManager, its elements, or elements array is empty/undefined.');
return null;
}
/**
* Gets the first visible social icon element from sidebar elements.
* @returns {object|null} The first suitable social element or null.
*/
function getFirstSocialElementForHeader() {
if (window.SidebarManager && window.SidebarManager.elements && window.SidebarManager.elements.length > 0) {
const visibleElements = window.SidebarManager.elements.filter(el => el.visible !== false); // Filter 1: visible
if (visibleElements.length > 0) {
const sortedElements = visibleElements.sort((a, b) => a.position - b.position);
for (let el of sortedElements) {
if (el.type === 'social') { // Filter for social type
return el;
}
}
// If loop finishes, no visible social element found
console.log('[getFirstSocialElementForHeader] Found visible elements, but none were type social.');
return null;
} else {
console.log('[getFirstSocialElementForHeader] No elements with visible !== false found in SidebarManager.elements.');
return null;
}
}
console.log('[getFirstSocialElementForHeader] SidebarManager, its elements, or elements array is empty/undefined.');
return null;
}
function isSpaceColonySiteTheme() {
const themeId = document.body && document.body.dataset.siteTheme;
return themeId === 'space-colony';
}
/**
* Apply horizontal bar logo placement (inline vs stacked) from Menu Style > Bar logo & menu.
* Sets body.horizontal-header-logo-stacked and flex on .logo-menu-container; updates --mobile-header-height when stacked.
*/
function applyHorizontalLogoMenuLayoutStyles() {
const sc = window.MenuStyleCustomizer && window.MenuStyleCustomizer.settings;
const mode = (sc && sc.horizontalHeaderLogoLayout) || 'inline';
const isSpaceColonyTheme = isSpaceColonySiteTheme();
document.body.classList.toggle('horizontal-header-logo-stacked', mode === 'stacked');
const w = document.querySelector('.logo-menu-container');
if (w) {
const isMobile = window.innerWidth <= 768;
w.style.display = 'flex';
w.style.alignItems = isSpaceColonyTheme && !isMobile ? 'flex-end' : 'center';
if (mode === 'stacked') {
w.style.flexDirection = 'column';
w.style.gap = isMobile ? '8px' : '12px';
w.style.width = '100%';
w.style.boxSizing = 'border-box';
w.style.flexGrow = isMobile ? '0' : '1';
w.style.minWidth = '0';
w.style.justifyContent = isMobile ? (isSpaceColonyTheme ? 'flex-start' : 'center') : 'flex-start';
} else {
w.style.flexDirection = 'row';
w.style.gap = '20px';
w.style.width = '';
w.style.boxSizing = '';
w.style.minWidth = '';
if (isMobile) {
w.style.flexGrow = '0';
w.style.justifyContent = isSpaceColonyTheme ? 'flex-start' : 'center';
} else {
w.style.flexGrow = '';
w.style.justifyContent = '';
}
}
}
const mhc = document.querySelector('.mobile-header-content');
if (mhc) {
const isMobile = window.innerWidth <= 768;
if (isSpaceColonyTheme && !isMobile) {
mhc.style.alignItems = 'flex-end';
mhc.style.flexGrow = '0';
mhc.style.width = '';
} else if (isSpaceColonyTheme && isMobile) {
mhc.style.alignItems = 'flex-start';
mhc.style.justifyContent = 'flex-start';
mhc.style.flexGrow = '';
mhc.style.width = '100%';
} else if (!isMobile && mode === 'stacked') {
mhc.style.alignItems = 'flex-start';
mhc.style.flexGrow = '';
mhc.style.width = '';
} else {
mhc.style.alignItems = '';
mhc.style.flexGrow = '';
mhc.style.width = '';
}
}
if (typeof window.syncContentMarginToHorizontalHeader === 'function') {
window.syncContentMarginToHorizontalHeader();
}
}
window.applyHorizontalLogoMenuLayoutStyles = applyHorizontalLogoMenuLayoutStyles;
function parseHorizontalHeaderHeightPx(value, fallback) {
if (value === null || value === undefined) {
return fallback;
}
const parsed = parseInt(String(value).trim(), 10);
if (Number.isNaN(parsed) || parsed < 1) {
return fallback;
}
return parsed;
}
function getUserHorizontalHeaderHeightPx() {
if (window.MenuStyleCustomizer && window.MenuStyleCustomizer.settings) {
const fromSetting = parseHorizontalHeaderHeightPx(window.MenuStyleCustomizer.settings.mobileHeaderHeight, 0);
if (fromSetting > 0) {
return fromSetting;
}
}
const fromVar = getComputedStyle(document.documentElement).getPropertyValue('--top-bar-height-setting').trim();
const parsedVar = parseHorizontalHeaderHeightPx(fromVar, 0);
if (parsedVar > 0) {
return parsedVar;
}
const themeMin = getComputedStyle(document.documentElement).getPropertyValue('--st-header-height').trim();
const parsedTheme = parseHorizontalHeaderHeightPx(themeMin, 0);
if (parsedTheme > 0) {
return parsedTheme;
}
return 60;
}
function setHorizontalHeaderHeightVars(headerPx, contentMarginPx) {
const headerVal = headerPx + 'px';
const marginVal = contentMarginPx + 'px';
const currentHeader = getComputedStyle(document.documentElement).getPropertyValue('--mobile-header-height').trim();
const currentMargin = getComputedStyle(document.documentElement).getPropertyValue('--content-margin-top').trim();
if (currentHeader === headerVal && currentMargin === marginVal) {
return;
}
document.documentElement.style.setProperty('--mobile-header-height', headerVal);
document.documentElement.style.setProperty('--content-margin-top', marginVal);
}
function measureHorizontalHeaderNaturalHeight(header) {
const el = header;
const prevHeight = el.style.getPropertyValue('height');
const prevMinHeight = el.style.getPropertyValue('min-height');
/* Beat CSS min-height: var(--mobile-header-height) !important so a prior wrap
doesn’t floor the measurement when the viewport widens back to one row. */
el.style.setProperty('height', 'auto', 'important');
el.style.setProperty('min-height', '0', 'important');
const measured = Math.ceil(el.getBoundingClientRect().height);
if (prevHeight) {
el.style.setProperty('height', prevHeight);
} else {
el.style.removeProperty('height');
}
if (prevMinHeight) {
el.style.setProperty('min-height', prevMinHeight);
} else {
el.style.removeProperty('min-height');
}
return measured;
}
function applyHorizontalHeaderContentInset() {
if (!document.body.classList.contains('menu-layout-horizontal')) {
return;
}
if (window._horizontalHeaderInsetApplying) {
return;
}
const header = document.querySelector('.mobile-header');
if (!header) {
return;
}
window._horizontalHeaderInsetApplying = true;
try {
const userH = getUserHorizontalHeaderHeightPx();
/* Keep logo-cap var as a real px height. Sites can save mobileHeaderHeight as 0vh;
leaving that on --top-bar-height-setting makes calc(var(...) - 8px) → 0 and hides the logo. */
document.documentElement.style.setProperty('--top-bar-height-setting', userH + 'px');
const hasExpandedMenu = header.querySelector('.horizontal-menu-item.expanded, .horizontal-menu-item.submenu-open');
const isStackedLogo = document.body.classList.contains('horizontal-header-logo-stacked');
const isPhone = window.innerWidth <= 768;
/*
* Phone hamburger chrome: fixed Style Customizer height (link row is hidden).
* Desktop inline may wrap beside the logo — measure like stacked so the bar can grow.
* Stacked / open submenu: always measure.
*/
if (!hasExpandedMenu && !isStackedLogo && isPhone) {
setHorizontalHeaderHeightVars(userH, userH);
return;
}
/* Desktop inline (wrap-capable), stacked logo, or open submenu */
const measured = measureHorizontalHeaderNaturalHeight(header);
if (!measured || measured < 1) {
setHorizontalHeaderHeightVars(userH, userH);
return;
}
let maxH = Math.max(200, Math.round(window.innerHeight * 0.5));
const clamped = Math.min(Math.max(measured, userH), maxH);
/* Extra content margin only when bar grew past the user setting (wrapped / stacked / open menu) */
const contentMargin = clamped > userH ? clamped + 8 : clamped;
setHorizontalHeaderHeightVars(clamped, contentMargin);
} finally {
window._horizontalHeaderInsetApplying = false;
}
}
function ensureHorizontalHeaderHeightObserver() {
if (typeof ResizeObserver === 'undefined') {
return;
}
const header = document.querySelector('.mobile-header');
if (!header) {
return;
}
if (window._horizontalHeaderResizeObserver) {
window._horizontalHeaderResizeObserver.disconnect();
}
window._horizontalHeaderResizeObserver = new ResizeObserver(function() {
applyHorizontalHeaderContentInset();
});
window._horizontalHeaderResizeObserver.observe(header);
}
function syncContentMarginToHorizontalHeader() {
if (!document.body.classList.contains('menu-layout-horizontal')) {
return;
}
requestAnimationFrame(function() {
requestAnimationFrame(function() {
applyHorizontalHeaderContentInset();
ensureHorizontalHeaderHeightObserver();
});
});
}
window.syncContentMarginToHorizontalHeader = syncContentMarginToHorizontalHeader;
window.applyHorizontalHeaderContentInset = applyHorizontalHeaderContentInset;
function getEffectiveMenuLayout() {
if (window.MenuStyleCustomizer && typeof window.MenuStyleCustomizer.getEffectiveMenuLayout === 'function') {
return window.MenuStyleCustomizer.getEffectiveMenuLayout();
}
if (document.body.classList.contains('menu-layout-horizontal')) return 'horizontal';
if (document.body.classList.contains('menu-layout-top')) return 'top';
if (window.MenuStyleCustomizer && window.MenuStyleCustomizer.settings) {
return window.MenuStyleCustomizer.settings.menuLayout || 'sidebar';
}
return 'sidebar';
}
function ensureCorrectLayoutApplied() {
// Check if MenuStyleCustomizer and its necessary parts exist
if (window.MenuStyleCustomizer && window.MenuStyleCustomizer.settings && typeof window.MenuStyleCustomizer._applyMenuLayout === 'function') {
// Prefer in-editor settings (e.g. Style History preview) over theme/body heuristics
const fromSettings = window.MenuStyleCustomizer.settings.menuLayout;
const currentLayout = fromSettings || getEffectiveMenuLayout();
console.log(`Ensuring layout is applied: ${currentLayout}`);
// Apply the layout styles (CSS classes, display properties)
window.MenuStyleCustomizer._applyMenuLayout(currentLayout);
// --- Force Header Structure Rebuild for Horizontal Layout ---
if (currentLayout === 'horizontal') {
const mobileHeader = document.querySelector('.mobile-header');
if (mobileHeader) {
let mobileHeaderContent = mobileHeader.querySelector('.mobile-header-content');
// Ensure the main content container exists
if (!mobileHeaderContent) {
mobileHeaderContent = document.createElement('div');
mobileHeaderContent.className = 'mobile-header-content';
const hamburgerBtn = mobileHeader.querySelector('.hamburger-btn');
if (hamburgerBtn) {
mobileHeader.insertBefore(mobileHeaderContent, hamburgerBtn);
} else {
mobileHeader.appendChild(mobileHeaderContent);
}
}
// Ensure specific sub-containers exist, creating them if necessary
// This guarantees the structure is present before renderHorizontalMenu fills them.
if (!mobileHeaderContent.querySelector('.horizontal-header-logo')) {
const logoContainer = document.createElement('div');
logoContainer.className = 'horizontal-header-logo';
mobileHeaderContent.insertBefore(logoContainer, mobileHeaderContent.firstChild);
}
if (!mobileHeaderContent.querySelector('.horizontal-menu-container')) {
const menuContainer = document.createElement('div');
menuContainer.className = 'horizontal-menu-container';
const logoContainer = mobileHeaderContent.querySelector('.horizontal-header-logo');
if (logoContainer) {
logoContainer.insertAdjacentElement('afterend', menuContainer);
} else { // Fallback if logo container wasn't found/created
mobileHeaderContent.insertBefore(menuContainer, mobileHeaderContent.firstChild);
}
}
// Apply flexbox styling to position social container at far right
mobileHeaderContent.style.display = 'flex';
const isSpaceColonyTheme = isSpaceColonySiteTheme();
const isMobile = window.innerWidth <= 768;
mobileHeaderContent.style.alignItems = isSpaceColonyTheme && !isMobile ? 'flex-end' : (isSpaceColonyTheme && isMobile ? 'flex-start' : 'center');
mobileHeaderContent.style.justifyContent = isMobile ? (isSpaceColonyTheme ? 'flex-start' : 'center') : (isSpaceColonyTheme ? 'flex-start' : 'space-between');
if (isSpaceColonyTheme && !isMobile) {
mobileHeaderContent.style.flexGrow = '0';
mobileHeaderContent.style.width = '';
} else {
mobileHeaderContent.style.flexGrow = '';
mobileHeaderContent.style.width = '100%';
}
// Create a flex container for logo and menu items
const logoMenuContainer = mobileHeaderContent.querySelector('.logo-menu-container');
if (!logoMenuContainer) {
console.log('[ensureCorrectLayoutApplied] Creating logo-menu wrapper...');
const logoMenuWrapper = document.createElement('div');
logoMenuWrapper.className = 'logo-menu-container';
// Move logo and menu into the wrapper
const logoContainer = mobileHeaderContent.querySelector('.horizontal-header-logo');
const menuContainer = mobileHeaderContent.querySelector('.horizontal-menu-container');
console.log('[ensureCorrectLayoutApplied] Found logo container:', !!logoContainer);
console.log('[ensureCorrectLayoutApplied] Found menu container:', !!menuContainer);
if (logoContainer && menuContainer) {
// Insert wrapper before the logo
mobileHeaderContent.insertBefore(logoMenuWrapper, logoContainer);
// Move both containers into the wrapper
logoMenuWrapper.appendChild(logoContainer);
logoMenuWrapper.appendChild(menuContainer);
console.log('[ensureCorrectLayoutApplied] Successfully moved logo and menu into wrapper');
} else {
console.warn('[ensureCorrectLayoutApplied] Missing logo or menu container, cannot create wrapper');
}
} else {
console.log('[ensureCorrectLayoutApplied] Logo-menu wrapper already exists');
}
if (typeof applyHorizontalLogoMenuLayoutStyles === 'function') {
applyHorizontalLogoMenuLayoutStyles();
}
// Add social icons container for horizontal layout (AFTER logo-menu wrapper is created)
if (!mobileHeaderContent.querySelector('.horizontal-social-container')) {
const socialContainer = document.createElement('div');
socialContainer.className = 'horizontal-social-container';
// Insert at the end (far right) of the mobile header content
mobileHeaderContent.appendChild(socialContainer);
}
}
}
// --- End Header Structure Rebuild ---
// Re-render the correct menu structure based on the applied layout
if (currentLayout === 'horizontal') {
// If horizontal layout, render the horizontal menu
if (typeof renderHorizontalMenu === 'function') {
renderHorizontalMenu();
} else {
console.warn("renderHorizontalMenu function not found.");
}
// After rendering horizontal menu, update mobile header content
// This ensures mobile header shows logo instead of menu items on mobile
setTimeout(() => {
if (typeof updateMobileHeaderContent === 'function') {
updateMobileHeaderContent();
}
}, 100);
} else {
// For sidebar or top layouts, render the standard gallery tree
if (typeof renderGalleries === 'function') {
renderGalleries();
} else {
console.warn("renderGalleries function not found.");
}
// Update mobile header content for other layouts too
setTimeout(() => {
if (typeof updateMobileHeaderContent === 'function') {
updateMobileHeaderContent();
}
}, 100);
}
} else {
// Log a warning if the necessary components aren't available
console.warn("MenuStyleCustomizer or its methods not available to re-apply layout.");
}
}
function applyLayoutStylesOnly() {
// Check if MenuStyleCustomizer and its necessary parts exist
if (window.MenuStyleCustomizer && window.MenuStyleCustomizer.settings && typeof window.MenuStyleCustomizer._applyMenuLayout === 'function') {
// Get the current layout setting, default to 'sidebar' if not set
const currentLayout = window.MenuStyleCustomizer.settings.menuLayout || 'sidebar';
console.log(`APPLY STYLES: Applying styles for layout: ${currentLayout}`);
// Apply the layout styles (CSS classes, display properties)
window.MenuStyleCustomizer._applyMenuLayout(currentLayout); // This function should ONLY set body classes/styles
} else {
console.warn("APPLY STYLES: MenuStyleCustomizer or its methods not available.");
}
}
// Add this function to ensure sidebar content for mobile
function ensureSidebarContentForMobile() {
const isHorizontalLayout = document.body.classList.contains('menu-layout-horizontal');
const isMobileView = window.innerWidth <= 768;
if (isHorizontalLayout && isMobileView) {
console.log('Ensuring sidebar content for mobile horizontal layout');
// Make sure the sidebar tree is populated
const galleryTreeContainer = document.getElementById('galleryTree');
if (galleryTreeContainer && (!galleryTreeContainer.innerHTML || galleryTreeContainer.innerHTML.trim() === '')) {
console.log('Sidebar tree is empty, populating with galleries');
// Render the galleries in the sidebar tree for mobile navigation
if (typeof renderGalleries === 'function') {
renderGalleries();
}
}
// Also ensure sidebar elements are rendered
if (window.SidebarManager && typeof window.SidebarManager.renderElements === 'function') {
console.log('Re-rendering sidebar elements for mobile');
window.SidebarManager.renderElements();
}
}
}
function renderTextLogo(container, htmlContentFromSidebarElement) {
if (!container) {
console.error("[RenderTextLogo] Error: Logo container not found.");
return;
}
container.innerHTML = '';
const raw = (htmlContentFromSidebarElement || '').trim();
if (!raw) {
container.innerHTML = '<span class="site-title-text">Menu</span>';
} else if (!/<[a-z][sS]*>/i.test(raw)) {
// Plain text — wrap so theme CSS can target .site-title-text
container.innerHTML = '<span class="site-title-text">' + raw + '</span>';
} else {
container.innerHTML = raw;
const titleEl = container.querySelector('.site-title-text, h1, h2, p:last-of-type, p:only-of-type');
if (titleEl && !titleEl.classList.contains('site-title-text')) {
titleEl.classList.add('site-title-text');
}
}
// Nested Quill <p> tags keep theme paragraph margins; reset so flex centering aligns with menu items
container.querySelectorAll('p').forEach(function(p) {
p.style.setProperty('margin-top', '0', 'important');
p.style.setProperty('margin-bottom', '0', 'important');
});
console.log(`[RenderTextLogo] Text logo HTML rendered directly into container.`);
}
function renderHorizontalMenu() {
console.log("[RenderHorizontalMenu] Function CALLED. Current edit mode:", typeof isEditing !== 'undefined' ? isEditing : 'isEditing_undefined');
// CRITICAL: Check if we're actually in horizontal layout (theme overlay counts)
const currentLayout = getEffectiveMenuLayout();
const isHorizontalLayout = currentLayout === 'horizontal';
console.log("[RenderHorizontalMenu] Current layout:", currentLayout, "isHorizontalLayout:", isHorizontalLayout);
// If not horizontal layout, only handle logo/alignment, then return early
if (!isHorizontalLayout) {
console.log("[RenderHorizontalMenu] Not in horizontal layout, only handling logo/alignment, skipping menu item creation");
// Still handle logo/alignment for sidebar layout
const mobileHeader = document.querySelector('.mobile-header');
if (mobileHeader) {
const logoContainer = mobileHeader.querySelector('.horizontal-header-logo');
if (logoContainer) {
// Handle logo rendering only (code below will handle this)
// But we'll return before creating menu items
}
}
// Return early - don't create horizontal menu items in sidebar layout
return;
}
// Wait for sidebar elements to be available before rendering
if (!window.SidebarManager || !window.SidebarManager.elements || window.SidebarManager.elements.length === 0) {
console.log("[RenderHorizontalMenu] Sidebar elements not yet available, waiting...");
const waitForSidebarElements = (attempts = 0) => {
const maxAttempts = 20; // Increased attempts for slower loads
if (window.SidebarManager && window.SidebarManager.elements && window.SidebarManager.elements.length > 0) {
console.log("[RenderHorizontalMenu] Sidebar elements now available, proceeding with render.");
renderHorizontalMenu();
} else if (attempts < maxAttempts) {
const attemptNum = attempts + 1;
setTimeout(() => waitForSidebarElements(attempts + 1), 100);
} else {
console.warn("[RenderHorizontalMenu] Sidebar elements not available after waiting, rendering anyway.");
// Continue with render even if sidebar elements aren't loaded
}
};
waitForSidebarElements();
return;
}
const mobileHeader = document.querySelector('.mobile-header');
if (!mobileHeader) {
console.error("[RenderHorizontalMenu] CRITICAL: .mobile-header element NOT FOUND.");
return;
}
// Apply flexbox styling to position social container at far right
const mobileHeaderContent = mobileHeader.querySelector('.mobile-header-content');
if (mobileHeaderContent) {
mobileHeaderContent.style.display = 'flex';
const isSpaceColonyTheme = isSpaceColonySiteTheme();
const isMobile = window.innerWidth <= 768;
mobileHeaderContent.style.alignItems = isSpaceColonyTheme && !isMobile ? 'flex-end' : (isSpaceColonyTheme && isMobile ? 'flex-start' : 'center');
mobileHeaderContent.style.justifyContent = isMobile ? (isSpaceColonyTheme ? 'flex-start' : 'center') : (isSpaceColonyTheme ? 'flex-start' : 'space-between');
if (isSpaceColonyTheme && !isMobile) {
mobileHeaderContent.style.flexGrow = '0';
mobileHeaderContent.style.width = '';
} else {
mobileHeaderContent.style.flexGrow = '';
mobileHeaderContent.style.width = '100%';
}
// Create a flex container for logo and menu items
const logoMenuContainer = mobileHeaderContent.querySelector('.logo-menu-container');
if (!logoMenuContainer) {
console.log('[RenderHorizontalMenu] Creating logo-menu wrapper...');
const logoMenuWrapper = document.createElement('div');
logoMenuWrapper.className = 'logo-menu-container';
// Move logo and menu into the wrapper
const logoContainer = mobileHeaderContent.querySelector('.horizontal-header-logo');
const menuContainer = mobileHeaderContent.querySelector('.horizontal-menu-container');
console.log('[RenderHorizontalMenu] Found logo container:', !!logoContainer);
console.log('[RenderHorizontalMenu] Found menu container:', !!menuContainer);
if (logoContainer && menuContainer) {
// Insert wrapper before the logo
mobileHeaderContent.insertBefore(logoMenuWrapper, logoContainer);
// Move both containers into the wrapper
logoMenuWrapper.appendChild(logoContainer);
logoMenuWrapper.appendChild(menuContainer);
console.log('[RenderHorizontalMenu] Successfully moved logo and menu into wrapper');
} else {
console.warn('[RenderHorizontalMenu] Missing logo or menu container, cannot create wrapper');
}
} else {
console.log('[RenderHorizontalMenu] Logo-menu wrapper already exists');
}
if (typeof applyHorizontalLogoMenuLayoutStyles === 'function') {
applyHorizontalLogoMenuLayoutStyles();
}
}
const logoContainer = mobileHeader.querySelector('.horizontal-header-logo');
if (!logoContainer) {
console.error("[RenderHorizontalMenu] CRITICAL: .horizontal-header-logo container NOT FOUND within .mobile-header.");
return;
}
console.log("[RenderHorizontalMenu] Found .horizontal-header-logo container");
logoContainer.innerHTML = ''; // Clear existing logo content first
// FIXED: Check if we're on mobile - if so, don't render the horizontal logo
const isMobileView = window.innerWidth <= 768; // Adjust breakpoint as needed
if (isMobileView) {
console.log("[RenderHorizontalMenu] Mobile view detected - skipping horizontal logo rendering");
// On mobile, the logo will be handled by updateMobileHeaderContent()
} else {
console.log("[RenderHorizontalMenu] Desktop view - rendering horizontal logo");
// Get the logo element data
let logoElementData = null;
try {
logoElementData = getFirstSidebarElementForHeader();
console.log('[RenderHorizontalMenu] getFirstSidebarElementForHeader() returned:', JSON.stringify(logoElementData));
} catch (error) {
console.error('[RenderHorizontalMenu] Error getting sidebar element:', error);
logoElementData = getFirstSidebarElementFallback();
}
if (logoElementData) {
if (logoElementData.type === 'image' && (logoElementData.imageData || logoElementData.imageUrl)) {
console.log('[RenderHorizontalMenu] Attempting to render IMAGE logo with src:', logoElementData.imageData || logoElementData.imageUrl);
const img = document.createElement('img');
img.src = logoElementData.imageUrl || logoElementData.imageData;
img.alt = resolveBrandLogoAlt(logoElementData);
img.className = 'sidebar-image';
// Apply comprehensive styling to ensure visibility
img.style.display = 'block';
// --- FIX ---
// The line below was removed. It was applying a fixed max-height of 50px,
// overriding your CSS. Removing it allows your stylesheet to take control.
// img.style.maxHeight = '50px';
img.style.objectFit = 'contain';
img.style.minWidth = '1px';
img.style.minHeight = '0';
let logoDisplayNode = img;
if (logoElementData.colorizeWithSiteColors &&
window.SidebarManager &&
typeof window.SidebarManager._buildSidebarImageDisplayNode === 'function') {
const buildData = Object.assign({}, logoElementData);
if (!buildData.imageWidth) {
buildData.imageWidth = 'auto';
}
logoDisplayNode = window.SidebarManager._buildSidebarImageDisplayNode(img, buildData);
} else {
img.style.width = 'auto';
img.style.visibility = 'visible';
img.style.opacity = '1';
}
// Opt-in per-image padding wrapper (no-op when unset; does not alter .horizontal-header-logo CSS)
if (window.SidebarManager &&
typeof window.SidebarManager._wrapWithImagePadding === 'function') {
logoDisplayNode = window.SidebarManager._wrapWithImagePadding(logoDisplayNode, logoElementData);
}
// Enhanced event listeners for debugging
img.onload = () => {
console.log('[RenderHorizontalMenu] Image successfully LOADED');
};
img.onerror = (error) => {
console.error('[RenderHorizontalMenu] Image FAILED to load:', error);
renderTextLogo(logoContainer, logoElementData.title || 'Logo');
};
try {
// Handle linking (similar to sidebar-manager.js)
// Only add links in view mode, not edit mode
// Explicit edit toggle only — Parameters.isInEditor is also true for logged-in admins
const isEditing = document.body.classList.contains('edit-mode-active') ||
(typeof window.isInEditMode === 'function' && window.isInEditMode());
// Debug: Log link properties and element data
console.log('[RenderHorizontalMenu] Full logoElementData:', logoElementData);
console.log('[RenderHorizontalMenu] Image link properties:', {
linkType: logoElementData.linkType,
linkUrl: logoElementData.linkUrl,
linkPageId: logoElementData.linkPageId,
linkTarget: logoElementData.linkTarget,
isEditing: isEditing,
hasLinkType: !!logoElementData.linkType,
linkTypeValue: logoElementData.linkType,
linkTypeNotNone: logoElementData.linkType && logoElementData.linkType !== 'none',
notEditing: !isEditing,
bodyHasEditClass: document.body.classList.contains('edit-mode-active'),
isInEditMode: typeof window.isInEditMode === 'function' ? window.isInEditMode() : 'N/A',
ParametersIsInEditor: window.Parameters ? window.Parameters.isInEditor : 'N/A'
});
// Check for link - handle undefined, null, empty string, or 'none'
const hasLink = logoElementData.linkType &&
logoElementData.linkType !== 'none' &&
logoElementData.linkType !== '' &&
!isEditing;
console.log('[RenderHorizontalMenu] Has link?', hasLink, 'linkType:', logoElementData.linkType);
if (hasLink) {
const link = document.createElement('a');
link.target = logoElementData.linkTarget || '_self';
let finalLinkHref = '#';
let isValidLink = false;
if (logoElementData.linkType === 'external' && logoElementData.linkUrl) {
finalLinkHref = logoElementData.linkUrl;
if (link.target === '_blank') {
link.rel = 'noopener noreferrer';
}
isValidLink = true;
} else if (logoElementData.linkType === 'internal' && logoElementData.linkPageId) {
const allGalleries = window.galleries || (typeof galleries !== 'undefined' ? galleries : []);
const linkPageIdNum = parseInt(logoElementData.linkPageId);
console.log('[RenderHorizontalMenu] Looking for internal page:', {
linkPageId: logoElementData.linkPageId,
linkPageIdNum: linkPageIdNum,
allGalleriesLength: allGalleries ? allGalleries.length : 0,
galleryIds: allGalleries ? allGalleries.map(g => ({ id: g.id, title: g.title })).slice(0, 5) : []
});
const targetPage = allGalleries.find(g => g.id === linkPageIdNum);
console.log('[RenderHorizontalMenu] Target page found:', targetPage ? { id: targetPage.id, title: targetPage.title, slug: targetPage.slug, url: targetPage.url } : null);
if (targetPage) {
// Handle preview mode URLs
const isPreviewMode = window.location.hostname === 'preview.neonsky.app';
console.log('[RenderHorizontalMenu] Preview mode:', isPreviewMode);
if (isPreviewMode) {
const pathParts = window.location.pathname.split('/').filter(Boolean);
const siteGuid = pathParts[0];
console.log('[RenderHorizontalMenu] Preview mode path parts:', pathParts, 'siteGuid:', siteGuid, 'targetPage.slug:', targetPage.slug);
if (siteGuid && targetPage.slug) {
finalLinkHref = '/' + siteGuid + '/' + targetPage.slug;
isValidLink = true;
console.log('[RenderHorizontalMenu] Set preview href:', finalLinkHref);
} else if (targetPage.url && targetPage.url.startsWith('/')) {
finalLinkHref = targetPage.url;
isValidLink = true;
console.log('[RenderHorizontalMenu] Set preview href from url:', finalLinkHref);
}
} else {
if (targetPage.slug) {
finalLinkHref = '/' + targetPage.slug;
isValidLink = true;
console.log('[RenderHorizontalMenu] Set non-preview href:', finalLinkHref);
} else if (targetPage.url && targetPage.url.startsWith('/')) {
finalLinkHref = targetPage.url;
isValidLink = true;
console.log('[RenderHorizontalMenu] Set non-preview href from url:', finalLinkHref);
}
}
} else {
console.warn('[RenderHorizontalMenu] Target page not found for linkPageId:', logoElementData.linkPageId);
}
}
if (isValidLink) {
link.href = finalLinkHref;
// Ensure link is clickable
link.style.pointerEvents = 'auto';
link.style.cursor = 'pointer';
link.style.textDecoration = 'none';
link.style.display = 'flex';
link.style.alignItems = 'center';
link.style.height = 'auto';
link.style.maxHeight = '100%';
link.style.minHeight = '0';
link.style.lineHeight = '0';
console.log('[RenderHorizontalMenu] Link created with href:', finalLinkHref, 'target:', link.target, 'styles applied');
// For internal links, add click handler to use site navigation
if (logoElementData.linkType === 'internal' && logoElementData.linkPageId) {
// Store linkPageId in a variable that won't be affected by closure
const linkPageId = logoElementData.linkPageId;
const linkHref = finalLinkHref;
// Add click handler with both capture and bubble phases to ensure it fires
const clickHandler = (e) => {
console.log('[RenderHorizontalMenu] Link clicked! linkPageId:', linkPageId, 'event:', e);
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const allGalleries = window.galleries || (typeof galleries !== 'undefined' ? galleries : []);
const linkPageIdNum = parseInt(linkPageId);
const targetPage = allGalleries.find(g => g.id === linkPageIdNum);
console.log('[RenderHorizontalMenu] Click handler - targetPage found:', targetPage ? { id: targetPage.id, title: targetPage.title, isPage: targetPage.isPage } : null);
console.log('[RenderHorizontalMenu] Click handler - loadPage function:', typeof window.loadPage, 'loadGallery function:', typeof window.loadGallery);
if (targetPage) {
if (isPageMenuItem(targetPage) && typeof window.loadPage === 'function') {
console.log('[RenderHorizontalMenu] Calling loadPage with id:', targetPage.id);
window.loadPage(targetPage.id);
} else if (typeof window.loadGallery === 'function') {
console.log('[RenderHorizontalMenu] Calling loadGallery with id:', targetPage.id);
window.loadGallery(targetPage.id);
} else {
// Fallback to standard navigation
console.log('[RenderHorizontalMenu] Fallback to window.location.href:', linkHref);
window.location.href = linkHref;
}
} else {
// Fallback to standard navigation if page not found
console.warn('[RenderHorizontalMenu] Target page not found in click handler, using fallback href:', linkHref);
window.location.href = linkHref;
}
return false;
};
// Add handler in capture phase to catch event early
link.addEventListener('click', clickHandler, true);
// Also add in bubble phase as backup
link.addEventListener('click', clickHandler, false);
// Also add mousedown as backup in case click is being prevented
link.addEventListener('mousedown', (e) => {
if (e.button === 0) { // Left click only
console.log('[RenderHorizontalMenu] MouseDown detected on link, triggering click handler');
clickHandler(e);
}
});
console.log('[RenderHorizontalMenu] Click handlers attached to link');
}
link.appendChild(logoDisplayNode);
logoContainer.appendChild(link);
console.log('[RenderHorizontalMenu] SUCCESSFULLY appended linked image to logoContainer. Link element:', link, 'href:', link.href, 'parent:', logoContainer);
// Verify link is in DOM and clickable
setTimeout(() => {
const verifyLink = logoContainer.querySelector('a');
if (verifyLink) {
console.log('[RenderHorizontalMenu] Link verification - Found in DOM:', {
href: verifyLink.href,
pointerEvents: window.getComputedStyle(verifyLink).pointerEvents,
cursor: window.getComputedStyle(verifyLink).cursor,
zIndex: window.getComputedStyle(verifyLink).zIndex,
position: window.getComputedStyle(verifyLink).position
});
} else {
console.warn('[RenderHorizontalMenu] Link verification - Link NOT found in DOM!');
}
}, 100);
} else {
console.warn('[RenderHorizontalMenu] Link is not valid, appending image without link. isValidLink:', isValidLink);
logoContainer.appendChild(logoDisplayNode);
console.log('[RenderHorizontalMenu] SUCCESSFULLY appended image to logoContainer.');
}
} else {
logoContainer.appendChild(logoDisplayNode);
console.log('[RenderHorizontalMenu] SUCCESSFULLY appended image to logoContainer.');
}
} catch (e) {
console.error('[RenderHorizontalMenu] Error during logoContainer.appendChild(img):', e);
renderTextLogo(logoContainer, logoElementData.title || 'Logo');
}
} else if (logoElementData.type === 'text' && logoElementData.textContent) {
console.log('[RenderHorizontalMenu] Attempting to render TEXT logo:', logoElementData.textContent);
renderTextLogo(logoContainer, logoElementData.textContent);
} else {
console.log('[RenderHorizontalMenu] Using title as fallback logo');
renderTextLogo(logoContainer, logoElementData.title || 'Menu');
}
} else {
console.log("[RenderHorizontalMenu] No logoElementData available. Using default text.");
renderTextLogo(logoContainer, 'Menu');
}
}
// Render menu items (only on desktop, not on mobile)
const menuContainer = mobileHeader.querySelector('.horizontal-menu-container');
if (!menuContainer) {
console.error("[RenderHorizontalMenu] .horizontal-menu-container NOT FOUND within .mobile-header.");
} else {
menuContainer.innerHTML = '';
// Apply alignment class from settings
if (window.MenuStyleCustomizer && window.MenuStyleCustomizer.settings) {
const rawAlign = window.MenuStyleCustomizer.settings.horizontalMenuAlignment || 'left';
const alignment = ['left', 'center', 'right'].indexOf(rawAlign) >= 0 ? rawAlign : 'left';
menuContainer.classList.remove('menu-align-left', 'menu-align-right', 'menu-align-center');
menuContainer.classList.add('menu-align-' + alignment);
console.log('[RenderHorizontalMenu] Applied alignment class: menu-align-' + alignment);
}
// Only populate menu items on desktop, not on mobile
if (!isMobileView) {
let galleryTree = [];
if (typeof createGalleryTree === 'function' && typeof galleries !== 'undefined') {
galleryTree = createGalleryTree(galleries);
} else {
console.warn("[RenderHorizontalMenu] createGalleryTree function or galleries array is undefined.");
}
galleryTree.forEach(item => {
const menuItemElement = createHorizontalMenuItem(item);
if (menuItemElement) {
menuContainer.appendChild(menuItemElement);
}
});
if (typeof syncSeoNavA11y === 'function') syncSeoNavA11y();
} else {
console.log("[RenderHorizontalMenu] Mobile view - skipping horizontal menu item population");
}
}
// Render social icons (only on desktop, not on mobile)
let socialContainer = mobileHeader.querySelector('.horizontal-social-container');
if (!socialContainer) {
// Fallback: create the social container if it doesn't exist
console.log("[RenderHorizontalMenu] .horizontal-social-container not found, creating it...");
const mobileHeaderContent = mobileHeader.querySelector('.mobile-header-content');
if (mobileHeaderContent) {
socialContainer = document.createElement('div');
socialContainer.className = 'horizontal-social-container';
mobileHeaderContent.appendChild(socialContainer);
console.log("[RenderHorizontalMenu] Created .horizontal-social-container");
} else {
console.error("[RenderHorizontalMenu] .mobile-header-content not found, cannot create social container");
}
}
if (socialContainer) {
socialContainer.innerHTML = '';
// Only populate social icons on desktop, not on mobile
if (!isMobileView) {
const socialElement = getFirstSocialElementForHeader();
if (socialElement && socialElement.type === 'social') {
console.log('[RenderHorizontalMenu] Found social element, rendering social icons');
renderSocialIcons(socialContainer, socialElement);
} else {
console.log('[RenderHorizontalMenu] No social element found or element is not social type');
// Container remains empty - no impact on layout
}
} else {
console.log("[RenderHorizontalMenu] Mobile view - skipping social icons population");
}
}
if (typeof updateActiveStatesHorizontal === 'function') {
updateActiveStatesHorizontal();
}
if (!isMobileView && mobileHeaderContent) {
mobileHeaderContent.querySelectorAll('.mobile-header-text, .mobile-header-menu-title, .mobile-header-title').forEach(function(el) {
if (!el.closest('.horizontal-header-logo')) {
el.remove();
}
});
}
setTimeout(() => {
ensureSidebarContentForMobile();
}, 100);
if (typeof syncContentMarginToHorizontalHeader === 'function') {
setTimeout(function() { syncContentMarginToHorizontalHeader(); }, 200);
}
console.log("[RenderHorizontalMenu] Function COMPLETED.");
}
/**
* Renders social icons in the horizontal header container.
* @param {HTMLElement} container - The container to render social icons in
* @param {object} socialElement - The social element data from sidebar
*/
function renderSocialIcons(container, socialElement) {
console.log('[RenderSocialIcons] Rendering social icons:', socialElement);
try {
// Safety check: ensure we have a valid social element
if (!socialElement || socialElement.type !== 'social') {
console.log('[RenderSocialIcons] Invalid social element - skipping rendering');
return;
}
/* Placeholders without URLs still need menu color (CSS also sets this). */
container.style.color = 'var(--menu-color, #333)';
// Get icon size with proper fallbacks
let iconSize = 30;
if (typeof socialElement.socialIconSize === 'number' && !isNaN(socialElement.socialIconSize)) {
iconSize = socialElement.socialIconSize;
} else if (typeof socialElement.socialIconSize === 'string' && !isNaN(parseInt(socialElement.socialIconSize))) {
iconSize = parseInt(socialElement.socialIconSize);
}
// Render icons directly from element data
if (socialElement.socialIcons && Array.isArray(socialElement.socialIcons)) {
socialElement.socialIcons.forEach(icon => {
// Create icon wrapper (color via CSS --menu-color so placeholders without URLs stay visible)
const iconWrapper = document.createElement('div');
iconWrapper.className = 'social-icon-wrapper';
iconWrapper.style.cssText = 'width: ' + iconSize + 'px !important; height: ' + iconSize + 'px !important; display: flex !important; align-items: center !important; justify-content: center !important; color: inherit !important;';
const applySvgSizing = (host) => {
const svg = host.querySelector('svg');
if (!svg) return;
svg.style.width = '100%';
svg.style.height = '100%';
svg.setAttribute('width', '100%');
svg.setAttribute('height', '100%');
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('focusable', 'false');
svg.style.color = 'inherit';
svg.style.fill = 'currentColor';
svg.style.stroke = 'currentColor';
};
const hasUrl = !!(icon.url && String(icon.url).trim());
// Add link if URL exists; otherwise still render the glyph (placeholder icons)
if (hasUrl) {
const link = document.createElement('a');
link.href = icon.url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.classList.add('horizontal-social-link');
link.style.cssText = 'display: flex !important; align-items: center !important; justify-content: center !important; width: 100% !important; height: 100% !important; color: inherit !important; text-decoration: none !important;';
const socialLabel = (icon.alt && String(icon.alt).trim())
|| (icon.type && icon.type !== 'custom' ? String(icon.type).replace(/_/g, ' ') : '')
|| 'Social link';
const socialLabelPretty = socialLabel.charAt(0).toUpperCase() + socialLabel.slice(1);
link.setAttribute('aria-label', socialLabelPretty + ' (opens in new tab)');
if (icon.type === 'custom' && icon.iconUrl) {
// Render uploaded image
const img = document.createElement('img');
img.src = icon.iconUrl;
img.alt = '';
img.setAttribute('aria-hidden', 'true');
img.style.width = '100%';
img.style.height = '100%';
img.style.objectFit = 'contain';
link.appendChild(img);
} else {
// Add SVG - use SidebarManager's method if available, otherwise create basic SVG
if (window.SidebarManager && typeof window.SidebarManager._getSocialIconSvg === 'function') {
link.innerHTML = window.SidebarManager._getSocialIconSvg(icon.type);
} else {
// Fallback: create a simple SVG placeholder
link.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg>';
}
applySvgSizing(link);
}
iconWrapper.appendChild(link);
} else {
// Placeholder: no URL — still paint with menu color via container inheritance
iconWrapper.setAttribute('aria-hidden', 'true');
if (icon.type === 'custom' && icon.iconUrl) {
const img = document.createElement('img');
img.src = icon.iconUrl;
img.alt = '';
img.style.width = '100%';
img.style.height = '100%';
img.style.objectFit = 'contain';
iconWrapper.appendChild(img);
} else {
if (window.SidebarManager && typeof window.SidebarManager._getSocialIconSvg === 'function') {
iconWrapper.innerHTML = window.SidebarManager._getSocialIconSvg(icon.type);
} else {
iconWrapper.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg>';
}
applySvgSizing(iconWrapper);
}
}
container.appendChild(iconWrapper);
console.log('[RenderSocialIcons] Rendered social icon:', icon.type, icon.url);
});
} else {
console.log('[RenderSocialIcons] No social icons found in element');
}
} catch (error) {
console.error('[RenderSocialIcons] Error rendering social icons:', error);
}
}
/**
* Gets the appropriate icon text or class for a social platform.
* @param {string} platform - The social platform name
* @returns {string} The icon text or class
*/
function getSocialIconText(platform) {
const iconMap = {
'facebook': 'f',
'twitter': 't',
'instagram': 'i',
'linkedin': 'in',
'youtube': 'yt',
'pinterest': 'p',
'tiktok': 'tt',
'snapchat': 'sc',
'whatsapp': 'wa',
'telegram': 'tg',
'discord': 'd',
'github': 'gh',
'email': '@',
'phone': '📞',
'website': '🌐'
};
return iconMap[platform.toLowerCase()] || '•';
}
/**
* Creates a single top-level horizontal menu item and its submenu if it has children.
* (UPDATED to handle async loadGallery and pass clickedItemId to a delayed updateActiveStatesHorizontal)
* @param {object} galleryItem - The gallery item data.
* @returns {HTMLElement | null} The created menu item element, or null if not rendered.
*/
function createHorizontalMenuItem(galleryItem) {
if (galleryItem.visible === false || galleryItem.isSpacer) {
return null;
}
const menuItem = document.createElement('div');
menuItem.className = 'horizontal-menu-item';
menuItem.dataset.id = String(galleryItem.id);
menuItem.setAttribute('data-visible', galleryItem.visible !== false ? 'true' : 'false');
const hasVisibleChildren = !!(galleryItem.children && galleryItem.children.filter(c => c.visible !== false && !c.isSpacer).length);
let titleEl;
if (hasVisibleChildren) {
titleEl = document.createElement('span');
titleEl.className = 'horizontal-menu-label';
titleEl.textContent = galleryItem.title;
menuItem.setAttribute('aria-haspopup', 'true');
menuItem.setAttribute('aria-expanded', 'false');
} else {
titleEl = document.createElement('a');
titleEl.className = 'horizontal-menu-label';
titleEl.href = typeof getMenuItemHref === 'function' ? getMenuItemHref(galleryItem) : '#';
titleEl.textContent = galleryItem.title;
if (galleryItem.isExternal) {
titleEl.target = '_blank';
titleEl.rel = 'noopener noreferrer';
} else {
titleEl.addEventListener('click', (e) => { e.preventDefault(); });
}
}
menuItem.appendChild(titleEl);
// Ensure activeGalleryId is treated as a number for comparison during initial render
const currentGlobalActiveId = parseInt(String(window.activeGalleryId || activeGalleryId));
if (galleryItem.id === currentGlobalActiveId) {
menuItem.classList.add('active');
}
let leaveTimer; // For mouseleave timeout to close dropdown
if (galleryItem.children && galleryItem.children.length > 0) {
const visibleChildren = galleryItem.children.filter(child => child.visible !== false && !child.isSpacer);
if (visibleChildren.length > 0) {
menuItem.classList.add('has-children');
const toggleIconContainer = document.createElement('span');
toggleIconContainer.className = 'submenu-toggle-horizontal';
const svgIcon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svgIcon.setAttribute("class", "icon toggle-icon");
svgIcon.setAttribute("viewBox", "0 0 24 24");
svgIcon.setAttribute("fill", "none");
svgIcon.setAttribute("stroke", "currentColor");
svgIcon.setAttribute("stroke-width", "2");
const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
polyline.setAttribute("points", "9 6 15 12 9 18"); // Right-pointing arrow
svgIcon.appendChild(polyline);
toggleIconContainer.appendChild(svgIcon);
menuItem.appendChild(toggleIconContainer);
const subMenu = document.createElement('div');
subMenu.className = 'horizontal-dropdown';
const subMenuList = document.createElement('ul');
subMenuList.className = 'horizontal-dropdown-list';
visibleChildren.forEach((childItem) => {
const subMenuItemElement = createHorizontalSubMenuItem(childItem); // Recursive call
if (subMenuItemElement) {
subMenuList.appendChild(subMenuItemElement);
}
});
if (subMenuList.hasChildNodes()) {
subMenu.appendChild(subMenuList);
menuItem.appendChild(subMenu);
menuItem.addEventListener('mouseenter', function() {
clearTimeout(leaveTimer);
document.querySelectorAll('.horizontal-menu-item.expanded').forEach((openItem) => {
if (openItem !== this) {
openItem.classList.remove('expanded');
openItem.setAttribute('aria-expanded', 'false');
const otherIcon = openItem.querySelector('.icon.toggle-icon');
if (otherIcon) otherIcon.classList.remove('rotated');
}
});
this.classList.add('expanded');
this.setAttribute('aria-expanded', 'true');
const currentIcon = this.querySelector('.icon.toggle-icon');
if (currentIcon) currentIcon.classList.add('rotated');
});
menuItem.addEventListener('mouseleave', function(event) {
// Check if we're moving to the dropdown or a flyout
const relatedTarget = event.relatedTarget;
const isMovingToDropdown = relatedTarget && (
relatedTarget.closest('.horizontal-dropdown') ||
relatedTarget.classList.contains('horizontal-submenu-item') ||
relatedTarget.closest('.horizontal-submenu-item')
);
const isMovingToFlyout = relatedTarget && (
relatedTarget.classList.contains('horizontal-nested-dropdown') ||
relatedTarget.closest('.horizontal-nested-dropdown')
);
if (!isMovingToDropdown && !isMovingToFlyout) {
console.log('Main menu item mouseleave - starting timer');
leaveTimer = setTimeout(() => {
// Check if there's an active flyout - if so, keep parent open
// Use the same multiple detection methods as the submenu handler
const activeFlyout = document.querySelector('.horizontal-nested-dropdown[style*="display: block"]') ||
document.querySelector('.horizontal-nested-dropdown[style*="visibility: visible"]') ||
document.querySelector('.horizontal-nested-dropdown:not([style*="display: none"])');
console.log('Main menu timer fired - active flyout:', activeFlyout);
if (!activeFlyout) {
console.log('Main menu closing dropdown - no active flyout');
this.classList.remove('expanded');
this.setAttribute('aria-expanded', 'false');
const currentIcon = this.querySelector('.icon.toggle-icon');
if (currentIcon) currentIcon.classList.remove('rotated');
} else {
console.log('Main menu keeping dropdown open - flyout is active');
}
}, 200);
} else {
console.log('Main menu item mouseleave - not starting timer (moving to dropdown or flyout)');
}
});
subMenu.addEventListener('mouseenter', function() {
const parentMenuItem = this.closest('.horizontal-menu-item');
if (parentMenuItem && parentMenuItem.leaveTimer) {
clearTimeout(parentMenuItem.leaveTimer);
parentMenuItem.leaveTimer = null;
}
});
subMenu.addEventListener('mouseleave', function(event) {
const parentMenuItem = this.closest('.horizontal-menu-item');
if (parentMenuItem) {
// Check if we're moving to another element within the same dropdown
const relatedTarget = event.relatedTarget;
console.log('SubMenu mouseleave - relatedTarget:', relatedTarget);
console.log('SubMenu mouseleave - relatedTarget classes:', relatedTarget ? relatedTarget.className : 'null');
// Check if we're moving to another element within the same dropdown
const currentDropdown = this.closest('.horizontal-dropdown') || this.closest('.horizontal-submenu')?.closest('.horizontal-dropdown');
console.log('Current dropdown found:', !!currentDropdown);
console.log('Current dropdown element:', currentDropdown);
console.log('RelatedTarget:', relatedTarget);
console.log('RelatedTarget closest dropdown:', relatedTarget ? relatedTarget.closest('.horizontal-dropdown') : 'null');
// Check if relatedTarget is within the same dropdown container
const isMovingWithinDropdown = relatedTarget && currentDropdown && (
relatedTarget.closest('.horizontal-dropdown') === currentDropdown ||
relatedTarget.classList.contains('horizontal-submenu-item') ||
relatedTarget.closest('.horizontal-submenu-item') ||
currentDropdown.contains(relatedTarget)
);
// Alternative approach: check if relatedTarget is within the same parent menu item
const parentMenuContainer = this.closest('.horizontal-menu-item');
const isMovingWithinParent = relatedTarget && parentMenuContainer && parentMenuContainer.contains(relatedTarget);
console.log('Is moving within parent menu item:', isMovingWithinParent);
console.log('Current dropdown contains relatedTarget:', currentDropdown ? currentDropdown.contains(relatedTarget) : 'no dropdown');
console.log('RelatedTarget closest dropdown === currentDropdown:', relatedTarget && currentDropdown ? relatedTarget.closest('.horizontal-dropdown') === currentDropdown : 'null');
// Also check if mouse is still within the dropdown bounds (in case relatedTarget is null or outside)
// Get the parent menu item's dropdown container
const parentMenuItem = this.closest('.horizontal-menu-item');
console.log('Parent menu item found:', !!parentMenuItem);
const dropdownContainer = parentMenuItem ? parentMenuItem.querySelector('.horizontal-dropdown') : null;
console.log('Dropdown container found:', !!dropdownContainer);
if (dropdownContainer) {
console.log('Dropdown container classes:', dropdownContainer.className);
console.log('Dropdown container display:', window.getComputedStyle(dropdownContainer).display);
console.log('Dropdown container position:', window.getComputedStyle(dropdownContainer).position);
}
// Get bounds from the visible dropdown (not the hidden one)
let dropdownRect = { left: 0, right: 0, top: 0, bottom: 0 };
if (dropdownContainer) {
const computedStyle = window.getComputedStyle(dropdownContainer);
if (computedStyle.display !== 'none') {
dropdownRect = dropdownContainer.getBoundingClientRect();
} else {
// If dropdown is hidden, try to get bounds from the parent menu item
const parentRect = parentMenuItem.getBoundingClientRect();
dropdownRect = parentRect;
}
}
const mouseX = event.clientX;
const mouseY = event.clientY;
const isMouseInDropdownBounds = mouseX >= dropdownRect.left && mouseX <= dropdownRect.right &&
mouseY >= dropdownRect.top && mouseY <= dropdownRect.bottom;
console.log('Dropdown bounds:', {
left: dropdownRect.left,
right: dropdownRect.right,
top: dropdownRect.top,
bottom: dropdownRect.bottom,
mouseX,
mouseY,
mouseInBounds: mouseX >= dropdownRect.left && mouseX <= dropdownRect.right && mouseY >= dropdownRect.top && mouseY <= dropdownRect.bottom
});
// If there's an active flyout, always keep the parent dropdown open
// Check multiple ways to detect active flyouts
const activeFlyout = document.querySelector('.horizontal-nested-dropdown[style*="display: block"]') ||
document.querySelector('.horizontal-nested-dropdown[style*="visibility: visible"]') ||
document.querySelector('.horizontal-nested-dropdown:not([style*="display: none"])');
console.log('Active flyout found:', !!activeFlyout);
if (activeFlyout) {
console.log('Active flyout display style:', activeFlyout.style.display);
console.log('Active flyout computed display:', window.getComputedStyle(activeFlyout).display);
console.log('Active flyout visibility style:', activeFlyout.style.visibility);
}
const hasActiveFlyout = !!activeFlyout;
// If the dropdown is hidden (display: none), we can't get accurate bounds
// So let's use a simpler approach - if we're moving to another submenu item, keep open
const isMovingToSubmenuItem = relatedTarget && (
relatedTarget.classList.contains('horizontal-submenu-item') ||
relatedTarget.closest('.horizontal-submenu-item')
);
console.log('Is moving to submenu item:', isMovingToSubmenuItem);
console.log('RelatedTarget is submenu item:', relatedTarget ? relatedTarget.classList.contains('horizontal-submenu-item') : 'null');
console.log('RelatedTarget has submenu item ancestor:', relatedTarget ? !!relatedTarget.closest('.horizontal-submenu-item') : 'null');
// If we're moving to another submenu item within the same dropdown, always keep open
// OR if there's an active flyout (regardless of where the mouse is), keep open
// Simple approach: if mouse is within dropdown bounds OR there's an active flyout, keep open
const shouldKeepOpen = isMouseInDropdownBounds || hasActiveFlyout || isMovingWithinDropdown || isMovingToSubmenuItem;
console.log('Is moving within dropdown:', isMovingWithinDropdown);
console.log('Is mouse in dropdown bounds:', isMouseInDropdownBounds);
console.log('Has active flyout:', hasActiveFlyout);
console.log('Should keep open:', shouldKeepOpen);
if (!shouldKeepOpen) {
console.log('SubMenu mouseleave - starting timer (moving outside dropdown)');
parentMenuItem.leaveTimer = setTimeout(() => {
// Check if there's an active flyout - if so, keep parent open
const activeFlyout = document.querySelector('.horizontal-nested-dropdown[style*="display: block"]');
console.log('Timer fired - active flyout:', activeFlyout);
if (!activeFlyout) {
console.log('Closing parent dropdown - no active flyout');
parentMenuItem.classList.remove('expanded');
const currentIcon = parentMenuItem.querySelector('.icon.toggle-icon');
if (currentIcon) currentIcon.classList.remove('rotated');
} else {
console.log('Keeping parent dropdown open - flyout is active');
}
}, 200);
} else {
console.log('SubMenu mouseleave - not starting timer (moving within dropdown)');
}
}
});
} else {
menuItem.classList.remove('has-children');
if (toggleIconContainer.parentNode) {
toggleIconContainer.remove();
}
}
}
}
// Click listener for items that are direct navigation links (not folders with children)
if (!menuItem.classList.contains('has-children')) {
menuItem.addEventListener('click', async (event) => {
event.stopPropagation();
const clickedItemId = galleryItem.id; // Capture the ID of the clicked item
// Check if it's an external URL first
if (galleryItem.isExternal && galleryItem.url) {
window.open(galleryItem.url, '_blank', 'noopener,noreferrer');
return;
}
if (isPageMenuItem(galleryItem)) {
if (typeof loadPage === 'function') {
loadPage(clickedItemId, event); // loadPage is synchronous
if (typeof updateActiveStatesHorizontal === 'function') {
console.log("Click Handler (Page): Calling updateActiveStatesHorizontal for ID:", clickedItemId);
// Use a minimal delay even for sync operations if page rendering has its own async microtasks
setTimeout(() => updateActiveStatesHorizontal(clickedItemId), 50);
}
}
} else { // It's a gallery
if (typeof loadGallery === 'function') {
console.log("Click Handler (Gallery): Awaiting loadGallery for ID:", clickedItemId);
await loadGallery(clickedItemId, event); // Await the asynchronous loadGallery
console.log("Click Handler (Gallery): loadGallery completed for ID:", clickedItemId, ". Calling updateActiveStatesHorizontal with delay.");
if (typeof updateActiveStatesHorizontal === 'function') {
setTimeout(() => {
console.log("[Delayed Update from Click] Calling updateActiveStatesHorizontal for gallery ID:", clickedItemId);
updateActiveStatesHorizontal(clickedItemId); // Pass the captured clickedItemId
}, 100); // Delay to allow gallery system to settle
}
}
}
// Close any other open dropdowns and flyouts
document.querySelectorAll('.horizontal-menu-item.expanded').forEach((openItem) => {
// Don't close the parent if a child within its dropdown was clicked (already handled by submenu click)
if (openItem !== menuItem.closest('.horizontal-menu-item.expanded')) {
openItem.classList.remove('expanded');
openItem.setAttribute('aria-expanded', 'false');
const icon = openItem.querySelector('.icon.toggle-icon');
if (icon) icon.classList.remove('rotated');
}
});
// Close all flyouts
document.querySelectorAll('.horizontal-nested-dropdown').forEach((flyout) => {
flyout.style.display = 'none';
flyout.style.opacity = '0';
flyout.style.visibility = 'hidden';
});
// Remove expanded class from all submenu items
document.querySelectorAll('.horizontal-submenu-item.expanded').forEach((subItem) => {
subItem.classList.remove('expanded');
});
});
} else {
// Keyboard: Enter/Space toggles hover-style dropdown without changing mouse behavior
menuItem.setAttribute('tabindex', '0');
menuItem.addEventListener('keydown', function(event) {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
const isOpen = this.classList.contains('expanded');
document.querySelectorAll('.horizontal-menu-item.expanded').forEach((openItem) => {
if (openItem !== this) {
openItem.classList.remove('expanded');
openItem.setAttribute('aria-expanded', 'false');
const otherIcon = openItem.querySelector('.icon.toggle-icon');
if (otherIcon) otherIcon.classList.remove('rotated');
}
});
this.classList.toggle('expanded', !isOpen);
this.setAttribute('aria-expanded', !isOpen ? 'true' : 'false');
const currentIcon = this.querySelector('.icon.toggle-icon');
if (currentIcon) currentIcon.classList.toggle('rotated', !isOpen);
});
}
return menuItem;
}
/**
* Creates a single horizontal submenu item.
* (UPDATED to handle nested children and async loadGallery)
* @param {object} galleryItem - The gallery item data for the submenu.
* @returns {HTMLElement | null} The created submenu item element, or null if not rendered.
*/
function createHorizontalSubMenuItem(galleryItem) {
if (galleryItem.visible === false || galleryItem.isSpacer) {
return null;
}
const subMenuItem = document.createElement('li');
subMenuItem.className = 'horizontal-submenu-item';
subMenuItem.dataset.id = String(galleryItem.id);
subMenuItem.setAttribute('data-visible', galleryItem.visible !== false ? 'true' : 'false');
const subVisibleChildren = (galleryItem.children || []).filter(child => child.visible !== false && !child.isSpacer);
let titleSpan;
if (subVisibleChildren.length > 0) {
titleSpan = document.createElement('span');
titleSpan.className = 'horizontal-menu-label';
titleSpan.textContent = galleryItem.title;
} else {
titleSpan = document.createElement('a');
titleSpan.className = 'horizontal-menu-label';
titleSpan.href = typeof getMenuItemHref === 'function' ? getMenuItemHref(galleryItem) : '#';
titleSpan.textContent = galleryItem.title;
if (galleryItem.isExternal) {
titleSpan.target = '_blank';
titleSpan.rel = 'noopener noreferrer';
} else {
titleSpan.addEventListener('click', (e) => { e.preventDefault(); });
}
}
subMenuItem.appendChild(titleSpan);
const currentGlobalActiveId = parseInt(String(window.activeGalleryId || activeGalleryId));
if (galleryItem.id === currentGlobalActiveId) {
subMenuItem.classList.add('active');
if (titleSpan.tagName === 'A') titleSpan.setAttribute('aria-current', 'page');
}
// Check if this submenu item has children (nested sub-sub items)
if (galleryItem.children && galleryItem.children.length > 0) {
const visibleChildren = subVisibleChildren;
if (visibleChildren.length > 0) {
subMenuItem.classList.add('has-children');
// Add arrow indicator for nested items (same as top-level menu)
const arrowSpan = document.createElement('span');
arrowSpan.className = 'submenu-arrow';
const svgIcon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svgIcon.setAttribute("class", "icon toggle-icon");
svgIcon.setAttribute("viewBox", "0 0 24 24");
svgIcon.setAttribute("fill", "none");
svgIcon.setAttribute("stroke", "currentColor");
svgIcon.setAttribute("stroke-width", "2");
const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
polyline.setAttribute("points", "9 6 15 12 9 18"); // Right-pointing arrow
svgIcon.appendChild(polyline);
arrowSpan.appendChild(svgIcon);
subMenuItem.appendChild(arrowSpan);
// Create nested submenu as a true flyout
const nestedSubMenu = document.createElement('div');
nestedSubMenu.className = 'horizontal-nested-dropdown';
// Create a list container for the nested items
const nestedList = document.createElement('ul');
nestedList.className = 'horizontal-nested-list';
visibleChildren.forEach((childItem) => {
const nestedSubMenuItem = createHorizontalSubMenuItem(childItem); // Recursive call
if (nestedSubMenuItem) {
nestedList.appendChild(nestedSubMenuItem);
}
});
nestedSubMenu.appendChild(nestedList);
if (nestedSubMenu.hasChildNodes()) {
// Append to document body for true flyout behavior
document.body.appendChild(nestedSubMenu);
// Store reference to the flyout for cleanup
const flyoutId = 'flyout-' + Date.now() + '-' + Math.random();
subMenuItem.setAttribute('data-flyout-id', flyoutId);
nestedSubMenu.setAttribute('data-flyout-id', flyoutId);
console.log('Created flyout with ID:', flyoutId, 'for item:', galleryItem.title); // Debug
console.log('Flyout element:', nestedSubMenu); // Debug
// Add hover events for nested submenu
let nestedLeaveTimer;
// Shared open/close used by hover AND keyboard — mouse path unchanged
function openHorizontalNestedFlyout(hostItem) {
if (!hostItem) return;
if (hostItem.nestedLeaveTimer) {
clearTimeout(hostItem.nestedLeaveTimer);
}
const parentMenuItem = hostItem.closest('.horizontal-menu-item');
if (parentMenuItem && parentMenuItem.leaveTimer) {
clearTimeout(parentMenuItem.leaveTimer);
}
document.querySelectorAll('.horizontal-submenu-item.expanded').forEach((openItem) => {
if (openItem !== hostItem) {
openItem.classList.remove('expanded');
openItem.setAttribute('aria-expanded', 'false');
const otherFlyoutId = openItem.getAttribute('data-flyout-id');
if (otherFlyoutId) {
const otherFlyout = document.querySelector('.horizontal-nested-dropdown[data-flyout-id="' + otherFlyoutId + '"]');
if (otherFlyout) {
otherFlyout.style.display = 'none';
otherFlyout.style.opacity = '0';
otherFlyout.style.visibility = 'hidden';
}
}
}
});
hostItem.classList.add('expanded');
hostItem.setAttribute('aria-expanded', 'true');
const hostFlyoutId = hostItem.getAttribute('data-flyout-id');
if (!hostFlyoutId) return;
const nestedDropdown = document.querySelector('.horizontal-nested-dropdown[data-flyout-id="' + hostFlyoutId + '"]');
if (!nestedDropdown) return;
const rect = hostItem.getBoundingClientRect();
const parentDropdown = hostItem.closest('.horizontal-dropdown');
const parentRect = parentDropdown ? parentDropdown.getBoundingClientRect() : rect;
const leftPos = parentRect.right - 1;
const topPos = rect.top;
nestedDropdown.style.left = leftPos + 'px';
nestedDropdown.style.top = topPos + 'px';
nestedDropdown.style.display = 'block';
nestedDropdown.style.opacity = '1';
nestedDropdown.style.visibility = 'visible';
nestedDropdown.style.position = 'fixed';
nestedDropdown.style.zIndex = '1251';
setTimeout(() => {
const dropdownRect = nestedDropdown.getBoundingClientRect();
if (dropdownRect.right > window.innerWidth) {
nestedDropdown.style.left = (parentRect.left - dropdownRect.width - 5) + 'px';
}
}, 10);
}
function closeHorizontalNestedFlyout(hostItem) {
if (!hostItem) return;
hostItem.classList.remove('expanded');
hostItem.setAttribute('aria-expanded', 'false');
const hostFlyoutId = hostItem.getAttribute('data-flyout-id');
if (!hostFlyoutId) return;
const nestedDropdown = document.querySelector('.horizontal-nested-dropdown[data-flyout-id="' + hostFlyoutId + '"]');
if (nestedDropdown) {
nestedDropdown.style.display = 'none';
nestedDropdown.style.opacity = '0';
nestedDropdown.style.visibility = 'hidden';
}
}
// Keyboard opens the same flyout hover shows — attrs on label so mouse click focus stays natural
if (titleSpan) {
titleSpan.setAttribute('tabindex', '0');
titleSpan.setAttribute('role', 'button');
titleSpan.setAttribute('aria-haspopup', 'true');
titleSpan.setAttribute('aria-expanded', 'false');
titleSpan.addEventListener('keydown', function(event) {
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowRight') {
event.preventDefault();
event.stopPropagation();
openHorizontalNestedFlyout(subMenuItem);
titleSpan.setAttribute('aria-expanded', 'true');
const hostFlyoutId = subMenuItem.getAttribute('data-flyout-id');
const nestedDropdown = hostFlyoutId
? document.querySelector('.horizontal-nested-dropdown[data-flyout-id="' + hostFlyoutId + '"]')
: null;
const firstLink = nestedDropdown && nestedDropdown.querySelector('a.horizontal-menu-label');
if (firstLink && typeof firstLink.focus === 'function') {
try { firstLink.focus(); } catch (e) {}
}
} else if (event.key === 'Escape' || event.key === 'ArrowLeft') {
event.preventDefault();
event.stopPropagation();
closeHorizontalNestedFlyout(subMenuItem);
titleSpan.setAttribute('aria-expanded', 'false');
}
});
}
subMenuItem.addEventListener('mouseenter', function() {
openHorizontalNestedFlyout(this);
if (titleSpan) titleSpan.setAttribute('aria-expanded', 'true');
});
subMenuItem.addEventListener('mouseleave', function(event) {
// Check if we're moving to the flyout
const flyoutId = this.getAttribute('data-flyout-id');
const relatedTarget = event.relatedTarget;
const isMovingToFlyout = relatedTarget && flyoutId &&
relatedTarget.closest('.horizontal-nested-dropdown[data-flyout-id="' + flyoutId + '"]');
// Also check if we're moving within the dropdown
const parentMenuItem = this.closest('.horizontal-menu-item');
const isMovingWithinDropdown = relatedTarget && parentMenuItem && parentMenuItem.contains(relatedTarget);
console.log('Submenu item mouseleave - moving to flyout:', isMovingToFlyout);
console.log('Submenu item mouseleave - moving within dropdown:', isMovingWithinDropdown);
if (!isMovingToFlyout && !isMovingWithinDropdown) {
this.nestedLeaveTimer = setTimeout(() => {
closeHorizontalNestedFlyout(this);
if (titleSpan) titleSpan.setAttribute('aria-expanded', 'false');
// Now that the flyout is closed, allow the parent dropdown to close
const parentMenuItem = this.closest('.horizontal-menu-item');
if (parentMenuItem) {
setTimeout(() => {
const activeFlyout = document.querySelector('.horizontal-nested-dropdown[style*="display: block"]');
if (!activeFlyout) {
parentMenuItem.classList.remove('expanded');
const currentIcon = parentMenuItem.querySelector('.icon.toggle-icon');
if (currentIcon) currentIcon.classList.remove('rotated');
}
}, 50);
}
}, 200);
} else {
console.log('Submenu item mouseleave - not starting timer (moving to flyout)');
}
});
nestedSubMenu.addEventListener('mouseenter', function() {
console.log('Flyout mouseenter - clearing timers');
clearTimeout(nestedLeaveTimer);
// Clear the parent dropdown's leave timer to keep it open
const flyoutId = this.getAttribute('data-flyout-id');
if (flyoutId) {
const parentSubItem = document.querySelector('.horizontal-submenu-item[data-flyout-id="' + flyoutId + '"]');
if (parentSubItem) {
const parentMenuItem = parentSubItem.closest('.horizontal-menu-item');
if (parentMenuItem && parentMenuItem.leaveTimer) {
console.log('Clearing parent menu item leave timer');
clearTimeout(parentMenuItem.leaveTimer);
parentMenuItem.leaveTimer = null;
} else {
console.log('No parent menu item leave timer to clear');
}
if (parentSubItem.nestedLeaveTimer) {
console.log('Clearing submenu item nested leave timer');
clearTimeout(parentSubItem.nestedLeaveTimer);
parentSubItem.nestedLeaveTimer = null;
}
}
}
});
nestedSubMenu.addEventListener('mouseleave', function() {
const flyoutId = this.getAttribute('data-flyout-id');
if (flyoutId) {
const parentSubItem = document.querySelector('.horizontal-submenu-item[data-flyout-id="' + flyoutId + '"]');
if (parentSubItem) {
nestedLeaveTimer = setTimeout(() => {
closeHorizontalNestedFlyout(parentSubItem);
const label = parentSubItem.querySelector('.horizontal-menu-label');
if (label) label.setAttribute('aria-expanded', 'false');
}, 200);
}
}
});
}
}
}
// Click listener for navigation (only if no children or if it's a direct link)
if (!subMenuItem.classList.contains('has-children') || isPageMenuItem(galleryItem)) {
subMenuItem.addEventListener('click', async (event) => {
event.stopPropagation();
const clickedItemId = galleryItem.id;
// Check if it's an external URL first
if (galleryItem.isExternal && galleryItem.url) {
window.open(galleryItem.url, '_blank', 'noopener,noreferrer');
return;
}
if (isPageMenuItem(galleryItem)) {
if (typeof loadPage === 'function') {
loadPage(clickedItemId, event);
if (typeof updateActiveStatesHorizontal === 'function') {
console.log("SubMenu Click Handler (Page): Calling updateActiveStatesHorizontal for ID:", clickedItemId);
setTimeout(() => updateActiveStatesHorizontal(clickedItemId), 50);
}
}
} else { // It's a gallery
if (typeof loadGallery === 'function') {
console.log("SubMenu Click Handler (Gallery): Awaiting loadGallery for ID:", clickedItemId);
await loadGallery(clickedItemId, event);
console.log("SubMenu Click Handler (Gallery): loadGallery completed for ID:", clickedItemId, ". Calling updateActiveStatesHorizontal with delay.");
if (typeof updateActiveStatesHorizontal === 'function') {
setTimeout(() => {
console.log("[Delayed Update from SubMenu Click] Calling updateActiveStatesHorizontal for gallery ID:", clickedItemId);
updateActiveStatesHorizontal(clickedItemId);
}, 100);
}
}
}
// Close all parent dropdowns and flyouts after navigation
document.querySelectorAll('.horizontal-menu-item.expanded, .horizontal-submenu-item.expanded').forEach((openItem) => {
openItem.classList.remove('expanded');
const icon = openItem.querySelector('.icon.toggle-icon');
if (icon) icon.classList.remove('rotated');
});
// Close all flyouts
document.querySelectorAll('.horizontal-nested-dropdown').forEach((flyout) => {
flyout.style.display = 'none';
flyout.style.opacity = '0';
flyout.style.visibility = 'hidden';
});
});
}
return subMenuItem;
}
/**
* Updates the 'active' class for items in the horizontal menu.
*/
function updateActiveStatesHorizontal(forcedActiveId = null) {
// Prioritize forcedActiveId if provided, otherwise use global state.
// Ensure the ID is treated as a number for comparison.
const targetId = forcedActiveId !== null
? parseInt(String(forcedActiveId))
: parseInt(String(window.activeGalleryId || activeGalleryId));
console.log("[Horizontal Menu Update] Initiated. Target Active ID:", targetId,
(forcedActiveId !== null ? `(Forced: ${forcedActiveId})` : "(Global)"),
"Type:", typeof targetId);
const menuItems = document.querySelectorAll('.horizontal-menu-item, .horizontal-submenu-item');
if (menuItems.length === 0 && !isNaN(targetId)) { // Check if targetId is a valid number
console.warn("[Horizontal Menu Update] No horizontal menu items found in DOM to update. Target ID was:", targetId);
return;
}
let activeItemSet = false;
menuItems.forEach(item => {
const itemIdStr = item.dataset.id;
if (!itemIdStr) {
// console.warn("[Horizontal Menu Update] Menu item found without a data-id attribute:", item);
return;
}
const itemId = parseInt(itemIdStr);
if (itemId === targetId) {
if (!item.classList.contains('active')) {
item.classList.add('active');
console.log(`[Horizontal Menu Update] ADDED .active to item ID ${itemId} ('${item.textContent.trim()}') using target ID ${targetId}`);
}
activeItemSet = true;
} else {
if (item.classList.contains('active')) {
item.classList.remove('active');
console.log(`[Horizontal Menu Update] REMOVED .active from item ID ${itemId} ('${item.textContent.trim()}') using target ID ${targetId}`);
}
}
});
if (!isNaN(targetId) && !activeItemSet) { // Check if targetId is a valid number
console.warn(`[Horizontal Menu Update] Target Active ID was ${targetId}, but NO matching horizontal menu item was made active. Check data-id attributes and item visibility.`);
}
const activeSubItem = document.querySelector('.horizontal-submenu-item.active');
if (activeSubItem) {
const parentTopItem = activeSubItem.closest('.horizontal-menu-item.has-children');
if (parentTopItem && !parentTopItem.classList.contains('active')) {
// parentTopItem.classList.add('active-parent'); // Optional class for styling parent
}
}
console.log("[Horizontal Menu Update] Completed for Target ID:", targetId);
}
// Listener for closing dropdowns when clicking outside
document.addEventListener('click', function(event) {
if (currentMenuLayout === 'horizontal') {
const openSubmenus = document.querySelectorAll('.horizontal-menu-item.expanded');
let clickedInsideSubmenuOrParent = false;
openSubmenus.forEach(submenuContainer => {
// Check if click is on the submenu container OR its direct parent menu item
if (submenuContainer.contains(event.target) ||
(submenuContainer.parentElement && submenuContainer.parentElement.contains(event.target) && submenuContainer.parentElement.classList.contains('horizontal-menu-item'))) {
clickedInsideSubmenuOrParent = true;
}
});
// If click is on another top-level item that is NOT expanded, also don't close
const targetMenuItem = event.target.closest('.horizontal-menu-item');
if (targetMenuItem && !targetMenuItem.classList.contains('expanded') && targetMenuItem.classList.contains('has-children')) {
clickedInsideSubmenuOrParent = true; // Don't close if clicking another parent to open it
}
if (!clickedInsideSubmenuOrParent) {
openSubmenus.forEach(submenuContainer => {
submenuContainer.classList.remove('expanded');
});
}
}
});
document.addEventListener('menuLayoutChanged', function(e) {
const newLayout = e.detail.layout;
console.log('EVENT: menuLayoutChanged detected. New layout:', newLayout, 'Current isEditing state:', isEditing);
// Update the global currentMenuLayout variable if you have one
if (typeof currentMenuLayout !== 'undefined') {
currentMenuLayout = newLayout;
}
const sidebar = document.querySelector('.sidebar'); // Ensure sidebar DOM element is selected
// Always clear and re-render the appropriate menu structure for the new layout.
// This is important for style changes (like font size) that affect dimensions.
const galleryTreeContainer = document.getElementById('galleryTree');
const mobileHeaderContent = document.querySelector('.mobile-header-content');
if (galleryTreeContainer) {
galleryTreeContainer.innerHTML = ''; // Clear existing tree
}
if (mobileHeaderContent) {
const horizontalMenuContainer = mobileHeaderContent.querySelector('.horizontal-menu-container');
if (horizontalMenuContainer) {
horizontalMenuContainer.innerHTML = ''; // Clear only items, not the container itself
}
}
// Render the correct menu structure based on the new layout
if (newLayout === 'horizontal') {
if (typeof renderHorizontalMenu === 'function') {
renderHorizontalMenu();
}
// CSS rules will handle sidebar visibility based on .edit-mode-active and .menu-layout-horizontal
} else { // 'sidebar' or 'top'
if (typeof renderGalleries === 'function') {
renderGalleries();
}
// CSS rules will handle sidebar visibility
}
// If currently in edit mode, ensure the UI components for editing are correctly displayed
// for the new layout, without fully toggling the mode off and on.
if (isEditing && typeof window.updateGlobalEditState === 'function') {
console.log('menuLayoutChanged: In edit mode. Ensuring edit UI is consistent for new layout.');
// 1. Forcefully ensure global state and body/sidebar classes are correct for edit mode.
// updateGlobalEditState should handle adding .edit-mode-active to body and .editing to sidebar.
window.updateGlobalEditState(true);
// 2. Re-initialize sortables. It's often safer to destroy existing ones first.
if (typeof initializeNestedSortables === 'function') {
if (typeof destroyNestedSortables === 'function') {
destroyNestedSortables();
}
setTimeout(initializeNestedSortables, 100); // Delay for DOM updates
}
// Also reinitialize for SidebarManager if it exists and handles its own sortables
if (window.SidebarManager && typeof window.SidebarManager._reinitializeNestedSortables === 'function') {
setTimeout(() => window.SidebarManager._reinitializeNestedSortables(), 150);
}
// 3. Ensure edit controls (the top bar of buttons in the sidebar) are visible
const editControls = document.querySelector('.edit-controls');
if (editControls) {
editControls.style.display = 'flex'; // Or your default display type for these controls
editControls.classList.add('visible');
}
// 4. If the layout is now horizontal, the sidebar (for editing) should be visible.
// The CSS rule `body.menu-layout-horizontal.edit-mode-active .sidebar { display: block !important; }`
// should handle this. This log is for confirmation.
if (newLayout === 'horizontal' && sidebar) {
console.log('menuLayoutChanged: Horizontal layout in edit mode, sidebar should be visible via CSS.');
}
// 5. If PageManager is active and a page is loaded, ensure its edit mode is also set.
if (window.PageManager && typeof window.PageManager.setEditMode === 'function' &&
window.PageManager.getCurrentPageId && window.PageManager.getCurrentPageId()) {
window.PageManager.setEditMode(true);
}
} else if (!isEditing) {
// If not in edit mode, CSS should handle the view state.
// This block can be used for any explicit view mode adjustments if CSS isn't sufficient.
console.log('menuLayoutChanged: Not in edit mode. CSS will handle view state.');
}
// Update mobile title and close mobile menu if open, regardless of edit mode
if (typeof updateMobileTitle === 'function') {
updateMobileTitle();
}
if (typeof closeMobileMenu === 'function') {
closeMobileMenu();
}
// Re-render the active page so row-band insets and recipe layout match the new menu shell
if (window.PageManager && typeof window.PageManager.renderElements === 'function' && window.PageManager.currentPageId) {
console.log('menuLayoutChanged: Re-rendering page', window.PageManager.currentPageId);
window.PageManager.renderElements(window.PageManager.currentPageId);
}
if (typeof ensureCorrectLayoutApplied === 'function') {
setTimeout(ensureCorrectLayoutApplied, 0);
}
});
// Make sure getFirstSidebarElementForHeader is available if called from other scripts
if (typeof window.getFirstSidebarElementForHeader === 'undefined') {
window.getFirstSidebarElementForHeader = getFirstSidebarElementForHeader;
}
// Make sure getFirstSocialElementForHeader is available if called from other scripts
if (typeof window.getFirstSocialElementForHeader === 'undefined') {
window.getFirstSocialElementForHeader = getFirstSocialElementForHeader;
}
// Make sure renderSocialIcons is available if called from other scripts
if (typeof window.renderSocialIcons === 'undefined') {
window.renderSocialIcons = renderSocialIcons;
}
if (typeof window.renderHorizontalMenu === 'undefined') {
window.renderHorizontalMenu = renderHorizontalMenu;
}
if (typeof window.createHorizontalMenuItem === 'undefined') {
window.createHorizontalMenuItem = createHorizontalMenuItem;
}
if (typeof window.createHorizontalSubMenuItem === 'undefined') {
window.createHorizontalSubMenuItem = createHorizontalSubMenuItem;
}
if (typeof window.updateActiveStatesHorizontal === 'undefined') {
window.updateActiveStatesHorizontal = updateActiveStatesHorizontal;
}
// Ensure toggleEditMode is globally available if it's the primary one.
if (typeof window.toggleEditMode === 'undefined' || window.toggleEditMode.toString().length < 100) { // Heuristic to check if it's a placeholder
window.toggleEditMode = toggleEditMode;
}
// Helper function to toggle edit controls visibility
function toggleEditControlsVisibility(show) {
const editControls = document.querySelector('.edit-controls');
if (editControls) {
if (show) {
editControls.classList.add('visible');
editControls.style.display = 'flex';
} else {
editControls.classList.remove('visible');
editControls.style.display = 'none';
}
}
}
/** Reliable + shortcut across US/Intl layouts, numpad, and browsers (key/keyCode differs). */
function isHydraPlusKey(e) {
if (e.repeat) return false;
if (e.ctrlKey || e.metaKey || e.altKey) return false;
if (e.key === '+' || e.key === 'Add') return true;
if (e.code === 'NumpadAdd') return true;
if ((e.code === 'Equal' || e.key === '=') && e.shiftKey) return true;
if (e.keyCode === 187 && e.shiftKey) return true;
if (e.keyCode === 107) return true;
return false;
}
function handleHydraPlusShortcut() {
try {
const token = window.didToken || localStorage.getItem('hydra_auth_token');
const isAdmin = window.isAdmin || localStorage.getItem('hydra_is_admin') === 'true';
if (token && isAdmin) {
console.log('User already logged in - toggling edit mode');
if (typeof window.toggleEditMode === 'function') {
window.toggleEditMode();
} else if (typeof toggleEditMode === 'function') {
toggleEditMode();
} else {
console.warn('toggleEditMode function not found');
}
} else {
const p = startOTPLogin();
if (p && typeof p.then === 'function') {
p.catch(function(err) {
console.error('startOTPLogin failed:', err);
});
}
}
} catch (err) {
console.error('Hydra + key handler error:', err);
}
}
document.addEventListener('keydown', function(e) {
if (!isHydraPlusKey(e)) return;
e.preventDefault();
handleHydraPlusShortcut();
}, true);
// Custom HTML pages load in a sandboxed iframe; + is captured there unless forwarded via postMessage.
function isHydraCustomPageHtmlPlusMessage(e) {
if (!e.data || e.data.source !== 'hydra-custom-page-html' || e.data.type !== 'plus-key') return false;
if (e.origin === window.location.origin) return true;
// Sandboxed iframes (no allow-same-origin) post with origin "null" — accept only from our iframe.
if (e.origin !== 'null' || !e.source) return false;
const iframes = document.querySelectorAll('iframe.page-custom-html-iframe');
for (let i = 0; i < iframes.length; i++) {
if (iframes[i].contentWindow === e.source) return true;
}
return false;
}
window.addEventListener('message', function(e) {
if (!isHydraCustomPageHtmlPlusMessage(e)) return;
handleHydraPlusShortcut();
});
/** Option+/ (÷) opens Help → Pathways. Option+? opens Help → Ask (help admin only). */
function isHydraHelpKey(e) {
if (e.repeat) return false;
if (e.ctrlKey || e.metaKey) return false;
if (!e.altKey) return false;
if (e.shiftKey) return false;
if (e.key === '÷') return true;
if (e.code === 'Slash' && e.altKey) return true;
return false;
}
function isHydraHelpAskKey(e) {
if (e.repeat) return false;
if (e.ctrlKey || e.metaKey) return false;
if (!e.altKey || !e.shiftKey) return false;
if (e.key === '?' || e.key === '¿') return true;
if (e.code === 'Slash') return true;
return false;
}
document.addEventListener('keydown', function(e) {
if (!window.HYDRA_HELP_ENABLED) return;
var t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
if (isHydraHelpAskKey(e)) {
e.preventDefault();
e.stopPropagation();
openHydraHelp({ initialTab: 'ask' });
return;
}
if (!isHydraHelpKey(e)) return;
e.preventDefault();
e.stopPropagation();
openHydraHelp();
}, true);
// Email OTP Authentication with Enhanced Debugging
// Add this function to the client-side code
// Improved client-side backup function with better token handling
async function createLoginBackup() {
try {
console.log('Attempting to create login backup...');
// Get authentication info from multiple possible sources
let token = window.didToken || localStorage.getItem('hydra_auth_token');
const email = localStorage.getItem('hydra_auth_email') ||
(window.userMetadata ? window.userMetadata.email : '') ||
'';
// Check if we have authentication
if (!token) {
console.error('No auth token available for backup');
return;
}
if (!email) {
console.error('No email available for backup - make sure it was stored during login');
return;
}
console.log(`Creating backup with token (${token.length} chars) for ${email}`);
// Format the token correctly - VERY IMPORTANT
// If it's already a hydra token (starts with hydra:), use it as is
// Otherwise, add the hydra: prefix
const formattedToken = token.startsWith('hydra:') ? token : `hydra:${token}`;
console.log(`Using formatted token: ${formattedToken.substring(0, 15)}...`);
// Get the API URL with preview handling if needed
const backupUrl = typeof getApiUrl === 'function' ?
getApiUrl('/api/create-backup') :
'/api/create-backup';
console.log(`Sending backup request to: ${backupUrl}`);
// IMPORTANT: Make sure to add the Bearer prefix to the Authorization header
const response = await fetch(backupUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${formattedToken}`,
'Content-Type': 'application/json',
'X-User-Email': email, // Include email as fallback
'X-API-Request': 'true' // Mark as API request
}
});
// Handle response
if (!response.ok) {
const errorText = await response.text();
let errorData;
try {
// Try to parse as JSON
errorData = JSON.parse(errorText);
console.error('Backup failed:', errorData.error, errorData);
} catch {
// Not JSON, log raw text
console.error('Backup failed with status', response.status, errorText);
}
return;
}
// Parse successful response
const result = await response.json();
console.log(`✅ Backup created successfully: ${result.backup}`);
} catch (error) {
console.error('Error in backup process:', error);
}
}
// Initialize Magic with detailed logging
function initMagic() {
try {
// If Magic is already initialized, return it
if (window.magic) {
return window.magic;
}
console.log('Initializing Magic SDK...');
// Initialize with minimal options
const magic = new Magic('pk_live_E0B68BA6DCA8C75F', {
testMode: false
});
// Store reference globally
window.magic = magic;
console.log('Magic SDK initialized successfully');
return magic;
} catch (error) {
console.error('Error initializing Magic:', error);
throw error;
}
}
// Start OTP login flow with proper event handling
async function startOTPLogin() {
try {
// Initialize Magic
const magic = initMagic();
// Get email from user (styled overlay — same auth path as native prompt)
const email = await showEmailLoginOverlay();
if (!email) return; // User cancelled
console.log('Starting Email OTP login for:', email);
showLoadingOverlay('Sending verification code...');
// Check if Magic's event enum types are available - for newer Magic SDK versions
// If not available, fall back to string-based events
const useEnums = typeof LoginWithEmailOTPEventOnReceived !== 'undefined' &&
typeof LoginWithEmailOTPEventEmit !== 'undefined';
console.log('Using enum-based events:', useEnums);
// Create a timeout handler
let emailSentTimeout = setTimeout(() => {
console.log('No email-otp-sent event received, using fallback UI');
hideLoadingOverlay();
showOTPInputOverlay(email);
}, 10000);
// Create OTP login handle with showUI: false
const handle = magic.auth.loginWithEmailOTP({
email,
showUI: false,
deviceCheckUI: false
});
// Track authentication state
window.pendingAuth = {
email,
handle
};
// Listen for events - using both enum-based and string-based event handling
console.log('Setting up OTP event handlers...');
// Email OTP sent event
if (useEnums) {
handle.on(LoginWithEmailOTPEventOnReceived.EmailOTPSent, () => {
console.log('EVENT: EmailOTPSent (enum) - Email was sent successfully');
clearTimeout(emailSentTimeout);
hideLoadingOverlay();
showOTPInputOverlay(email);
});
}
// Also listen for string-based event (for backward compatibility)
handle.on('email-otp-sent', () => {
console.log('EVENT: email-otp-sent - Email was sent successfully');
clearTimeout(emailSentTimeout);
hideLoadingOverlay();
showOTPInputOverlay(email);
});
// Invalid OTP event
if (useEnums) {
handle.on(LoginWithEmailOTPEventOnReceived.InvalidEmailOtp, () => {
console.log('EVENT: InvalidEmailOtp (enum) - Invalid code entered');
showErrorMessage('Invalid verification code. Please try again.');
updateOTPInputForRetry();
});
}
// Also listen for string-based event
handle.on('invalid-email-otp', () => {
console.log('EVENT: invalid-email-otp - Invalid code entered');
showErrorMessage('Invalid verification code. Please try again.');
updateOTPInputForRetry();
});
async function handleLoginSuccess(result) {
console.log('OTP verification successful, checking admin status...');
hideOTPInputOverlay();
hideLoadingOverlay();
try {
window.didToken = result;
console.log('DID Token received:', result ? (result.substring(0, 20) + '...') : 'none');
const userInfo = await magic.user.getInfo();
window.userMetadata = userInfo;
console.log('User info retrieved:', userInfo);
const isUserAdmin = await checkAdminStatus(result);
if (!isUserAdmin && !window.isAdmin) {
console.log('User is not on admin list, login denied');
showErrorMessage('Login failed');
clearHydraClientAuthStorage();
resetAuthUI();
try {
await magic.user.logout();
} catch (logoutError) {
console.error('Error logging out non-admin user from Magic:', logoutError);
}
window.pendingAuth = null;
return;
}
console.log('Admin status check completed');
window.isAuthenticated = true;
isAuthenticated = true;
showSuccessMessage('Login successful!');
// Store the ID token (JWT) for API calls
try {
const idToken = await magic.user.getIdToken();
if (idToken && idToken.includes('.')) {
console.log('Got proper JWT token from Magic.link');
localStorage.setItem('hydra_auth_token', idToken);
} else if (userInfo && userInfo.email) {
const userToken = {
email: userInfo.email,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + (24 * 60 * 60)
};
const header = btoa(JSON.stringify({ typ: 'JWT', alg: 'none' }));
const payload = btoa(JSON.stringify(userToken));
const jwtToken = header + '.' + payload + '.nosignature';
localStorage.setItem('hydra_auth_token', jwtToken);
localStorage.setItem('hydra_auth_email', userInfo.email);
}
localStorage.setItem('hydra_did_token', result);
} catch (tokenError) {
console.error('Error getting ID token:', tokenError);
localStorage.setItem('hydra_auth_token', result);
}
// IMPROVED UI UPDATE APPROACH - Use multiple techniques for redundancy
// 1. Add state classes to body
document.body.classList.add('hydra-authenticated');
if (window.isAdmin) {
document.body.classList.add('hydra-admin');
}
// 2. Direct DOM manipulation
const logoutButton = document.getElementById('logoutButton');
const editButton = document.getElementById('editButton');
// Create new buttons if they don't exist
if (!logoutButton) {
console.log('Creating logout button');
const newLogoutButton = document.createElement('button');
newLogoutButton.id = 'logoutButton';
newLogoutButton.className = 'btn';
newLogoutButton.textContent = 'Logout';
newLogoutButton.onclick = logout;
newLogoutButton.style.display = 'block';
const sidebarHeader = document.querySelector('.sidebar-header');
if (sidebarHeader) {
sidebarHeader.appendChild(newLogoutButton);
} else {
document.body.appendChild(newLogoutButton);
}
} else {
logoutButton.style.cssText = 'display: block !important; visibility: visible !important; opacity: 1 !important;';
}
if (!editButton && window.isAdmin) {
console.log('Creating edit button');
const newEditButton = document.createElement('button');
newEditButton.id = 'editButton';
newEditButton.className = 'btn btn-primary';
newEditButton.innerHTML = `
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
Edit
`;
newEditButton.onclick = toggleEditMode;
newEditButton.style.display = 'block';
const sidebarHeader = document.querySelector('.sidebar-header');
if (sidebarHeader) {
sidebarHeader.appendChild(newEditButton);
} else {
document.body.appendChild(newEditButton);
}
} else if (editButton && window.isAdmin) {
editButton.style.cssText = 'display: block !important; visibility: visible !important; opacity: 1 !important;';
}
// 3. Dispatch event for other listeners
document.dispatchEvent(new CustomEvent('login-complete', {
detail: {
isAdmin: window.isAdmin,
email: window.userMetadata ? window.userMetadata.email : null
}
}));
window.pendingAuth = null;
if (typeof checkAuth === 'function') {
console.log('Running existing checkAuth function');
setTimeout(checkAuth, 500);
}
} catch (error) {
console.error('Error in login success handling:', error);
showErrorMessage('Login failed');
clearHydraClientAuthStorage();
resetAuthUI();
window.pendingAuth = null;
}
}
// Completion event
handle.on('done', async (result) => {
await handleLoginSuccess(result);
});
// Error event
handle.on('error', (error) => {
console.error('EVENT: error - OTP login error:', error);
hideLoadingOverlay();
hideOTPInputOverlay();
showErrorMessage('Login failed: ' + (error.message || 'Unknown error'));
// Clear timeout if still active
clearTimeout(emailSentTimeout);
// Clean up
window.pendingAuth = null;
});
console.log('OTP event handlers set up successfully');
} catch (error) {
console.error('Error starting OTP login:', error);
hideLoadingOverlay();
hideOTPInputOverlay();
showErrorMessage('Error starting login: ' + error.message);
}
}
// Submit OTP code with support for enum-based events
function submitOTP(code) {
try {
// Validate code input
if (!code || code.trim() === '') {
showErrorMessage('Please enter the verification code.');
return;
}
// Check if we have a pending authentication
if (!window.pendingAuth || !window.pendingAuth.handle) {
showErrorMessage('No active login session. Please try again.');
hideOTPInputOverlay();
return;
}
console.log('Submitting OTP code...');
showLoadingOverlay('Verifying code...');
// Check if enum types are available
const useEnums = typeof LoginWithEmailOTPEventEmit !== 'undefined';
// Verify the OTP code
if (useEnums) {
window.pendingAuth.handle.emit(LoginWithEmailOTPEventEmit.VerifyEmailOtp, code);
} else {
window.pendingAuth.handle.emit('verify-email-otp', code);
}
} catch (error) {
console.error('Error submitting OTP:', error);
hideLoadingOverlay();
showErrorMessage('Error verifying code: ' + error.message);
}
}
// Cancel OTP login with support for enum-based events
function cancelOTPLogin() {
try {
// Check if we have a pending authentication
if (window.pendingAuth && window.pendingAuth.handle) {
// Check if enum types are available
const useEnums = typeof LoginWithEmailOTPEventEmit !== 'undefined';
// Emit cancel event
if (useEnums) {
window.pendingAuth.handle.emit(LoginWithEmailOTPEventEmit.Cancel);
} else {
window.pendingAuth.handle.emit('cancel');
}
console.log('OTP login cancelled');
}
// Clean up
window.pendingAuth = null;
// Hide any overlays
hideEmailLoginOverlay();
hideOTPInputOverlay();
hideLoadingOverlay();
} catch (error) {
console.error('Error cancelling OTP login:', error);
}
}
// Update OTP input for retry
function updateOTPInputForRetry() {
const otpInput = document.getElementById('otp-input');
if (otpInput) {
otpInput.value = '';
otpInput.focus();
// Add a shake animation for visual feedback
otpInput.classList.add('shake');
// Remove the animation class after it completes
setTimeout(() => {
otpInput.classList.remove('shake');
}, 500);
}
// Hide loading overlay if it's visible
hideLoadingOverlay();
}
function isValidLoginEmail(value) {
const trimmed = String(value || '').trim();
if (!trimmed || trimmed.indexOf(' ') >= 0) return false;
const at = trimmed.indexOf('@');
if (at <= 0 || at !== trimmed.lastIndexOf('@')) return false;
const local = trimmed.slice(0, at);
const domain = trimmed.slice(at + 1);
if (!local || !domain || domain.indexOf('.') < 1) return false;
if (domain.startsWith('.') || domain.endsWith('.')) return false;
return true;
}
function showEmailLoginOverlay() {
hideEmailLoginOverlay();
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.id = 'email-login-overlay';
overlay.className = 'pe-auth-overlay';
const content = document.createElement('div');
content.className = 'pe-auth-shell';
const logo = document.createElement('img');
logo.src = 'https://cdn.neonsky.app/neon-sky-logo.png';
logo.alt = 'Neon Sky Logo';
logo.className = 'pe-auth-shell__logo';
const description = document.createElement('p');
description.className = 'pe-auth-shell__desc';
description.textContent = 'Enter your email address to receive a verification code.';
const form = document.createElement('form');
form.noValidate = true;
form.onsubmit = function(e) {
e.preventDefault();
const raw = input.value.trim();
if (!isValidLoginEmail(raw)) {
input.classList.add('shake');
setTimeout(() => input.classList.remove('shake'), 500);
return;
}
hideEmailLoginOverlay();
resolve(raw);
};
const input = document.createElement('input');
input.id = 'email-login-input';
input.type = 'email';
input.name = 'email';
input.placeholder = 'you@example.com';
input.autocomplete = 'email';
input.inputMode = 'email';
input.required = true;
input.className = 'pe-auth-shell__input pe-auth-shell__input--email';
const savedEmail = localStorage.getItem('hydra_auth_email');
if (savedEmail && isValidLoginEmail(savedEmail)) {
input.value = savedEmail.trim();
}
form.appendChild(input);
const buttonContainer = document.createElement('div');
buttonContainer.className = 'pe-auth-actions';
const continueButton = document.createElement('button');
continueButton.type = 'submit';
continueButton.textContent = 'Continue';
continueButton.className = 'pe-btn pe-btn--primary';
buttonContainer.appendChild(continueButton);
const cancelButton = document.createElement('button');
cancelButton.type = 'button';
cancelButton.textContent = 'Cancel';
cancelButton.className = 'pe-btn';
cancelButton.onclick = function() {
hideEmailLoginOverlay();
resolve(null);
};
buttonContainer.appendChild(cancelButton);
form.appendChild(buttonContainer);
content.appendChild(logo);
content.appendChild(description);
content.appendChild(form);
overlay.appendChild(content);
document.body.appendChild(overlay);
setTimeout(() => {
input.focus();
if (input.value) {
input.select();
}
}, 100);
});
}
function hideEmailLoginOverlay() {
const overlay = document.getElementById('email-login-overlay');
if (overlay && overlay.parentNode) {
overlay.parentNode.removeChild(overlay);
}
}
// Show OTP input overlay - improved version
function showOTPInputOverlay(email) {
hideOTPInputOverlay();
hideEmailLoginOverlay();
const overlay = document.createElement('div');
overlay.id = 'otp-input-overlay';
overlay.className = 'pe-auth-overlay';
const content = document.createElement('div');
content.className = 'pe-auth-shell';
const logo = document.createElement('img');
logo.src = 'https://cdn.neonsky.app/neon-sky-logo.png';
logo.alt = 'Neon Sky Logo';
logo.className = 'pe-auth-shell__logo';
const description = document.createElement('p');
description.className = 'pe-auth-shell__desc';
const descPrefix = document.createTextNode("We've sent a verification code to ");
const descEmail = document.createElement('strong');
descEmail.textContent = String(email || '');
const descSuffix = document.createTextNode('. Please check your email and enter the code below:');
description.appendChild(descPrefix);
description.appendChild(descEmail);
description.appendChild(document.createElement('br'));
description.appendChild(descSuffix);
const form = document.createElement('form');
form.onsubmit = function(e) {
e.preventDefault();
const otpInput = document.getElementById('otp-input');
if (otpInput && otpInput.value) {
submitOTP(otpInput.value);
}
};
const input = document.createElement('input');
input.id = 'otp-input';
input.type = 'text';
input.placeholder = 'Enter code';
input.pattern = '[0-9]*';
input.inputMode = 'numeric';
input.autocomplete = 'one-time-code';
input.className = 'pe-auth-shell__input';
form.appendChild(input);
const buttonContainer = document.createElement('div');
buttonContainer.className = 'pe-auth-actions';
const verifyButton = document.createElement('button');
verifyButton.type = 'submit';
verifyButton.textContent = 'Verify';
verifyButton.className = 'pe-btn pe-btn--primary';
buttonContainer.appendChild(verifyButton);
const cancelButton = document.createElement('button');
cancelButton.type = 'button';
cancelButton.textContent = 'Cancel';
cancelButton.className = 'pe-btn';
cancelButton.onclick = function() {
cancelOTPLogin();
};
buttonContainer.appendChild(cancelButton);
form.appendChild(buttonContainer);
const resendContainer = document.createElement('div');
resendContainer.className = 'pe-auth-resend';
const resendText = document.createElement('span');
resendText.textContent = "Didn't receive the code? ";
const resendLink = document.createElement('a');
resendLink.textContent = 'Resend';
resendLink.href = '#';
resendLink.onclick = function(e) {
e.preventDefault();
cancelOTPLogin();
setTimeout(() => {
startOTPLogin();
}, 500);
};
resendContainer.appendChild(resendText);
resendContainer.appendChild(resendLink);
form.appendChild(resendContainer);
content.appendChild(logo);
content.appendChild(description);
content.appendChild(form);
overlay.appendChild(content);
document.body.appendChild(overlay);
setTimeout(() => {
input.focus();
}, 100);
}
// Hide OTP input overlay
function hideOTPInputOverlay() {
const overlay = document.getElementById('otp-input-overlay');
if (overlay && overlay.parentNode) {
overlay.parentNode.removeChild(overlay);
}
}
// Utility function to show loading overlay
function showLoadingOverlay(message = 'Loading...') {
hideLoadingOverlay();
const overlay = document.createElement('div');
overlay.id = 'loading-overlay';
overlay.className = 'pe-auth-overlay';
const content = document.createElement('div');
content.className = 'pe-auth-shell';
const loaderContainer = document.createElement('div');
loaderContainer.className = 'pe-auth-shell__loader';
loaderContainer.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" viewBox="0 0 24 24">
<defs>
<filter id="svgSpinnersGooeyBalls20">
<feGaussianBlur in="SourceGraphic" result="y" stdDeviation="1"/>
<feColorMatrix in="y" result="z" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -7"/>
<feBlend in="SourceGraphic" in2="z"/>
</filter>
</defs>
<g filter="url(#svgSpinnersGooeyBalls20)">
<circle cx="5" cy="12" r="4" fill="currentColor">
<animate attributeName="cx" calcMode="spline" dur="2s" keySplines=".36,.62,.43,.99;.79,0,.58,.57" repeatCount="indefinite" values="5;8;5"/>
</circle>
<circle cx="19" cy="12" r="4" fill="currentColor">
<animate attributeName="cx" calcMode="spline" dur="2s" keySplines=".36,.62,.43,.99;.79,0,.58,.57" repeatCount="indefinite" values="19;16;19"/>
</circle>
<animateTransform attributeName="transform" dur="0.75s" repeatCount="indefinite" type="rotate" values="0 12 12;360 12 12"/>
</g>
</svg>
`;
content.appendChild(loaderContainer);
const messageEl = document.createElement('p');
messageEl.className = 'pe-auth-shell__message';
messageEl.textContent = message;
content.appendChild(messageEl);
overlay.appendChild(content);
document.body.appendChild(overlay);
}
// Utility function to hide loading overlay
function hideLoadingOverlay() {
const overlay = document.getElementById('loading-overlay');
if (overlay && overlay.parentNode) {
overlay.parentNode.removeChild(overlay);
}
}
// Utility function to show success message
function showSuccessMessage(message, duration = 3000) {
const existingToast = document.getElementById('success-toast');
if (existingToast && existingToast.parentNode) {
existingToast.parentNode.removeChild(existingToast);
}
const toast = document.createElement('div');
toast.id = 'success-toast';
toast.className = 'pe-auth-toast';
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, duration);
}
// Utility function to show error message
function showErrorMessage(message, duration = 5000) {
const existingToast = document.getElementById('error-toast');
if (existingToast && existingToast.parentNode) {
existingToast.parentNode.removeChild(existingToast);
}
const toast = document.createElement('div');
toast.id = 'error-toast';
toast.className = 'pe-auth-toast pe-auth-toast--error';
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, duration);
}
// UPDATED: Check authentication status with class-based approach
async function checkAuth() {
try {
if (!magic) {
console.log('Magic not initialized, skipping auth check');
return;
}
// Prevent multiple simultaneous auth checks
if (window._checkingAuth) return;
window._checkingAuth = true;
console.log('Checking auth status...');
isAuthenticated = await magic.user.isLoggedIn();
console.log('isLoggedIn check:', isAuthenticated);
if (isAuthenticated) {
// Add the authenticated class
document.body.classList.add('hydra-authenticated');
// Get user metadata
userMetadata = await magic.user.getInfo();
console.log('User info:', userMetadata);
// Get ID token for API calls (JWT format)
const rawDidToken = await magic.user.getIdToken();
console.log('ID token acquired, length:', rawDidToken.length);
// Do NOT write the raw DID token to localStorage here — checkAdminStatus will
// receive the server-issued hydra token and store that instead, which is what
// the save function needs. Writing the DID token now would overwrite a valid
// hydra token that may already be stored from the OTP login flow.
// Check if user is admin; on success this updates both localStorage and didToken
// with a proper hydra token via checkAdminStatus → TokenManager.storeToken
await checkAdminStatus(rawDidToken);
} else {
// Magic has no session — still try Hydra OTP token so /_manage and return visits work
const hydraOnly = localStorage.getItem('hydra_auth_token');
if (hydraOnly) {
try {
await checkAdminStatus(hydraOnly);
} catch (e) {
console.error('Hydra token restore after Magic logout failed:', e);
}
} else {
resetAuthUI();
}
}
// Release lock
window._checkingAuth = false;
} catch (error) {
console.error('Error checking authentication:', error);
window._checkingAuth = false;
resetAuthUI();
}
}
async function checkAdminStatus(token) {
try {
console.log('Checking admin status...');
// Add user email to headers if available
let userEmail = null;
if (window.userMetadata && window.userMetadata.email) {
userEmail = window.userMetadata.email;
console.log('Using email from userMetadata:', userEmail);
} else {
userEmail = localStorage.getItem('hydra_auth_email'); // Fallback to localStorage
if (userEmail) console.log('Using email from localStorage:', userEmail);
}
if (!token) {
console.log('No token provided to checkAdminStatus');
return false; // Indicate failure
}
// Format the token correctly for the API call
let formattedToken = token;
// Ensure Bearer prefix is added for Magic tokens, or use hydra: prefix
if (!token.startsWith('hydra:') && !token.startsWith('Bearer ')) {
formattedToken = `Bearer ${token}`; // Assume Magic/JWT needs Bearer
} else if (token.startsWith('hydra:')) {
formattedToken = `Bearer ${token}`; // API expects Bearer even for hydra tokens
}
// If it already starts with Bearer, use as is
// Get the API URL, handling preview mode
const apiUrl = typeof getApiUrl === 'function' ?
getApiUrl('/api/check-admin') :
'/api/check-admin';
console.log('Sending admin check request to:', apiUrl);
// Create request headers
const headers = {
'Authorization': formattedToken,
'X-Hydra-Request': 'true' // Indicate this might be part of Hydra flow
};
if (userEmail) {
headers['X-User-Email'] = userEmail;
}
console.log('Admin check headers:', {
'Authorization': `${formattedToken.substring(0, 20)}...`,
'X-User-Email': userEmail || 'Not available'
});
// Add timeout to the fetch request
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
let data;
try {
const response = await fetch(apiUrl, {
method: 'GET',
headers: headers,
signal: controller.signal
});
clearTimeout(timeoutId); // Clear timeout if fetch succeeds
console.log('Admin check response status:', response.status);
// Get the response as text first for better debugging
const responseText = await response.text();
console.log('Admin check response length:', responseText.length);
console.log('Admin check response text (first 100 chars):', responseText.substring(0, 100) + '...');
try {
data = JSON.parse(responseText);
console.log('Admin check data:', data);
} catch (parseError) {
console.error('Error parsing admin check response:', parseError);
// Handle cases where the response might not be JSON (e.g., HTML error page)
// For preview URLs, let's still assume admin to allow editing flow
if (window.location.hostname === 'preview.neonsky.app') {
console.log('Preview URL detected, assuming admin status due to non-JSON response');
isAdmin = true;
document.body.classList.add('hydra-admin');
localStorage.setItem('hydra_is_admin', 'true');
forceShowEditUI(); // Attempt to show UI
return true; // Indicate potential success for preview
}
throw new Error(`Failed to parse admin check response: ${responseText.substring(0, 100)}...`);
}
if (!response.ok) {
// Throw an error with details from the parsed JSON if available
throw new Error(`Admin check failed: ${response.status} - ${data?.error || responseText.substring(0, 50)}`);
}
// Process the admin status
isAdmin = data.isAdmin;
window.isAdmin = isAdmin; // Update global flag
// Update classes based on admin status
if (isAdmin) {
document.body.classList.add('hydra-admin');
localStorage.setItem('hydra_is_admin', 'true');
// Store email if we got it from the server response
if (data.email) {
localStorage.setItem('hydra_auth_email', data.email);
} else if (userEmail) {
localStorage.setItem('hydra_auth_email', userEmail); // Store the email we used
}
} else {
document.body.classList.remove('hydra-admin');
localStorage.removeItem('hydra_is_admin');
localStorage.removeItem('hydra_auth_email'); // Clear email if not admin
}
// Update siteId if provided
if (data.siteId) {
siteId = data.siteId;
window.siteId = siteId;
if (window.Parameters) window.Parameters.siteId = siteId;
}
// Store new token if provided (Hydra token)
if (data.hydraToken) {
console.log('Received hydra token:', data.hydraToken.substring(0, 10) + '...');
const cleanHydraToken = data.hydraToken.startsWith('hydra:') ? data.hydraToken.substring(6) : data.hydraToken;
// Always update the local didToken variable so saves immediately use the hydra token
didToken = cleanHydraToken;
// Store token globally and in localStorage using TokenManager if available
if (window.TokenManager && typeof window.TokenManager.storeToken === 'function') {
window.TokenManager.storeToken(data.hydraToken, data.email || userEmail || '');
} else {
// Fallback storage
localStorage.setItem('hydra_auth_token', cleanHydraToken);
if (data.email || userEmail) {
localStorage.setItem('hydra_auth_email', data.email || userEmail);
}
}
console.log('Stored credentials in localStorage');
}
// Update UI based on admin status
if (isAdmin) {
console.log('Admin status confirmed, showing edit UI');
forceShowEditUI(); // Ensure UI elements are visible
// --- MODIFICATION START ---
// If layout is horizontal, re-render the menu to show controls
let currentLayout = 'sidebar'; // Default
if (window.MenuStyleCustomizer && window.MenuStyleCustomizer.settings) {
currentLayout = window.MenuStyleCustomizer.settings.menuLayout || 'sidebar';
}
if (currentLayout === 'horizontal') {
console.log('checkAdminStatus: Horizontal layout detected, re-rendering horizontal menu.');
if (typeof renderHorizontalMenu === 'function') {
renderHorizontalMenu();
} else {
console.warn("checkAdminStatus: renderHorizontalMenu function not found.");
}
}
// --- MODIFICATION END ---
createLoginBackup(); // Create backup after successful login/admin check
} else {
console.log('User is not admin, resetting UI.');
resetAuthUI(); // Ensure non-admin UI state
}
return isAdmin; // Return the admin status
} catch (fetchError) {
clearTimeout(timeoutId); // Clear timeout on error as well
console.error('Fetch error during admin check:', fetchError);
// For preview URLs, still allow editing as a fallback
if (window.location.hostname === 'preview.neonsky.app') {
console.log('Preview URL + fetch error, still enabling editing');
isAdmin = true;
window.isAdmin = true;
document.body.classList.add('hydra-admin');
localStorage.setItem('hydra_is_admin', 'true');
forceShowEditUI();
return true; // Indicate potential success for preview
}
// For non-preview, throw the error to indicate failure
throw fetchError;
}
} catch (error) {
console.error('Error in checkAdminStatus:', error);
// Don't show alert here, let the calling function handle UI feedback
// Reset UI to non-admin state on error
resetAuthUI();
return false; // Indicate failure
}
}
// UPDATED: Force showing edit UI with class-based approach
function forceShowEditUI() {
console.log('Forcing admin/auth state for UI');
// Ensure global state flags are set
isAuthenticated = true;
isAdmin = true;
window.isAuthenticated = true;
window.isAdmin = true;
// Add admin/auth classes to body - CSS will handle button visibility
document.body.classList.add('hydra-authenticated', 'hydra-admin');
// Store admin status in localStorage
localStorage.setItem('hydra_is_admin', 'true');
// Show edit controls container ONLY if already in edit mode
if (window.isInEditMode && window.isInEditMode()) {
const editControls = document.querySelector('.edit-controls');
if (editControls) {
editControls.classList.add('visible');
editControls.style.display = 'flex'; // Make sure container is flex
}
// Ensure controls inside items are visible if already editing
document.querySelectorAll('.controls, .element-controls, .edit-btn, .delete-btn, .visibility-toggle').forEach(el => {
if (el.closest('.sidebar.editing') || el.closest('.page-container.editing')) {
el.style.display = 'flex';
}
});
// Show logout button in edit controls when in edit mode
const logoutButton = document.getElementById('logoutButton');
if (logoutButton) {
logoutButton.style.display = 'flex';
logoutButton.classList.add('visible');
}
} else {
// Ensure edit controls container is hidden if not in edit mode
const editControls = document.querySelector('.edit-controls');
if (editControls) {
editControls.classList.remove('visible');
editControls.style.display = 'none';
}
// Hide logout button when not in edit mode
const logoutButton = document.getElementById('logoutButton');
if (logoutButton) {
logoutButton.style.display = 'none';
logoutButton.classList.remove('visible');
}
}
console.log('Admin/Auth classes set. CSS should handle button visibility.');
}
// UPDATED: Reset auth UI with class-based approach
function resetAuthUI() {
document.body.classList.remove('hydra-authenticated');
document.body.classList.remove('hydra-admin');
const logoutButton = document.getElementById('logoutButton');
if (logoutButton) {
logoutButton.style.display = 'none';
logoutButton.classList.remove('visible');
}
const editButton = document.getElementById('editButton');
if (editButton) {
editButton.style.display = 'none';
editButton.classList.remove('visible');
}
// Remove admin status from localStorage
localStorage.removeItem('hydra_is_admin');
localStorage.removeItem('hydra_auth_token');
if (isEditing) {
toggleEditMode(); // Exit edit mode if active
}
}
// Debug function - add to your code during testing
function debugUIClasses() {
console.log('Current body classes:', document.body.className);
console.log('hydra-initialized:', document.body.classList.contains('hydra-initialized'));
console.log('hydra-authenticated:', document.body.classList.contains('hydra-authenticated'));
console.log('hydra-admin:', document.body.classList.contains('hydra-admin'));
console.log('edit-mode-active:', document.body.classList.contains('edit-mode-active'));
}
async function logout() {
console.log('Logout initiated');
try {
// Show loading indication (optional)
showLoadingOverlay('Logging out...');
// 1. Properly logout from Magic SDK
if (window.magic) {
try {
await window.magic.user.logout();
console.log('Magic SDK logout successful');
} catch (magicError) {
console.error('Error during Magic logout:', magicError);
// Continue with other logout steps even if Magic SDK logout fails
}
}
// 2. Reset all authentication state variables
clearHydraClientAuthStorage();
// 3. Reset all UI state - remove authentication classes from body
document.body.classList.remove('hydra-authenticated');
document.body.classList.remove('hydra-admin');
// 5. If in edit mode, exit it first
if (document.body.classList.contains('edit-mode-active')) {
console.log('Exiting edit mode before logout');
// If toggleEditMode exists, call it to exit edit mode
if (typeof toggleEditMode === 'function') {
try {
toggleEditMode();
} catch (editError) {
console.error('Error exiting edit mode:', editError);
}
}
// Ensure edit mode class is removed regardless
document.body.classList.remove('edit-mode-active');
}
// 6. Ensure all edit UI elements are hidden
// Hide edit and logout buttons
const editButton = document.getElementById('editButton');
if (editButton) {
editButton.classList.remove('visible');
editButton.style.display = 'none';
}
const logoutButton = document.getElementById('logoutButton');
if (logoutButton) {
logoutButton.classList.remove('visible');
logoutButton.style.display = 'none';
}
// Hide edit controls
const editControls = document.querySelector('.edit-controls');
if (editControls) {
editControls.style.display = 'none';
editControls.classList.remove('visible');
}
// Hide sidebar header if it should be hidden when logged out
const sidebarHeader = document.querySelector('.sidebar-header');
if (sidebarHeader) {
sidebarHeader.style.display = 'none';
}
// Hide all element controls
document.querySelectorAll('.controls, .element-controls, .edit-btn, .delete-btn, .visibility-toggle').forEach(el => {
el.style.display = 'none';
});
// 7. Reset editor state if applicable
if (window.Parameters) {
window.Parameters.isInEditor = false;
}
// Hide any editing forms that might be open
const forms = document.querySelectorAll('.add-form, .edit-form, .element-edit-form, #menuStyleEditor, #metadataEditor, #importClassicForm, #sidebarElementForm');
forms.forEach(form => {
form.style.display = 'none';
if (form.classList.contains('visible')) {
form.classList.remove('visible');
}
});
// Remove editing class from sidebar
const sidebar = document.querySelector('.sidebar');
if (sidebar) {
sidebar.classList.remove('editing');
}
// 8. Clear any page-specific state
if (window.PageManager && typeof window.PageManager.clearCurrentPage === 'function') {
window.PageManager.clearCurrentPage();
}
// 9. Notify any components that need to know about logout
document.dispatchEvent(new CustomEvent('user-logout', {
detail: { timestamp: Date.now() }
}));
// 10. Show success message
hideLoadingOverlay();
showSuccessMessage('Successfully logged out');
console.log('Logout complete');
// 11. Optional: Refresh the page after a short delay for a clean state
// Uncomment the following lines if you want the page to refresh after logout
/*
setTimeout(() => {
window.location.reload();
}, 1500);
*/
} catch (error) {
console.error('Error during logout process:', error);
hideLoadingOverlay();
showErrorMessage('Error during logout. Please refresh the page.');
}
}
// Ensure the logout button is properly connected to the logout function
document.addEventListener('DOMContentLoaded', function() {
const logoutButton = document.getElementById('logoutButton');
if (logoutButton) {
// Remove any existing event listeners to avoid duplicates
const newButton = logoutButton.cloneNode(true);
logoutButton.parentNode.replaceChild(newButton, logoutButton);
// Add fresh event listener
newButton.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
logout();
});
}
});
function stopAutoAdvanceTimer() {
if (window.currentAutoAdvanceTimerId) {
clearTimeout(window.currentAutoAdvanceTimerId);
window.currentAutoAdvanceTimerId = null;
console.log('Auto-advance timer stopped.');
}
}
window.stopAutoAdvanceTimer = stopAutoAdvanceTimer;
function toggleEditMode() {
// Prevent multiple simultaneous calls
if (window._editModeToggleInProgress) {
console.log('Edit mode toggle already in progress, skipping');
return;
}
window._editModeToggleInProgress = true;
console.log("toggleEditMode called. Current window.isEditing:", window.isEditing, "Current window.isInEditMode():", window.isInEditMode ? window.isInEditMode() : 'undefined');
stopAutoAdvanceTimer(); // Assumes this function exists and stops any slideshow/auto-advance
// Authentication and Authorization Checks
if (!window.isAuthenticated && !(localStorage.getItem('hydra_is_admin') === 'true')) {
alert('You must be logged in to edit');
window._editModeToggleInProgress = false;
return;
}
if (localStorage.getItem('hydra_is_admin') === 'true' && !window.isAdmin) {
window.isAdmin = true; // Synchronize global flag
}
if (!window.isAdmin) {
alert('You must be an admin to edit');
window._editModeToggleInProgress = false;
return;
}
const currentEditState = window.isInEditMode ? window.isInEditMode() : (typeof window.isEditing !== 'undefined' ? window.isEditing : false);
const newEditState = !currentEditState;
console.log(`Changing edit mode from ${currentEditState} to: ${newEditState}`);
// Apply body class for edit mode styling
if (newEditState) {
console.log('Entering edit mode - setting body class and theme');
document.body.setAttribute('data-theme', 'pico'); // Optional: if Pico theme is used for edit mode
document.body.classList.add('edit-mode-active');
} else {
console.log('Exiting edit mode - removing body class and theme');
document.body.removeAttribute('data-theme');
document.body.classList.remove('edit-mode-active');
}
// Update the global state and dispatch event
if (typeof window.updateGlobalEditState === 'function') {
window.updateGlobalEditState(newEditState);
} else {
window.isEditing = newEditState; // Fallback
document.dispatchEvent(new CustomEvent('edit-mode-changed', { detail: { editing: newEditState } }));
if (!newEditState && typeof window.closeOptionsPanel === 'function') {
window.closeOptionsPanel();
}
}
if (document.body.classList.contains('menu-layout-horizontal') && typeof syncContentMarginToHorizontalHeader === 'function') {
setTimeout(syncContentMarginToHorizontalHeader, 50);
setTimeout(syncContentMarginToHorizontalHeader, 350);
}
const sidebarEditControls = document.querySelector('.edit-controls');
if (newEditState) {
// Entering edit mode
if (sidebarEditControls) {
sidebarEditControls.style.display = 'flex';
sidebarEditControls.classList.add('visible');
}
// Show logout button in edit controls when entering edit mode
const logoutButton = document.getElementById('logoutButton');
if (logoutButton) {
logoutButton.style.display = 'flex';
logoutButton.classList.add('visible');
}
// Always render the tree-like menu in the sidebar for editing
if (typeof window.renderGalleries === 'function') {
console.log('toggleEditMode (Edit): Rendering sidebar menu (gallery tree) for editing.');
window.renderGalleries(); // This will show all items, including those with visible:false
}
setTimeout(() => {
if (typeof window.initializeNestedSortables === 'function') {
console.log('Initializing nested sortables for edit mode');
window.initializeNestedSortables();
}
if (window.SidebarManager && typeof window.SidebarManager._reinitializeNestedSortables === 'function') {
window.SidebarManager._reinitializeNestedSortables();
}
}, 150);
// If the currently active item was invisible, reload it now that we are in edit mode.
if(window.galleries && window.activeGalleryId){
const currentGalleryItem = window.galleries.find(g => g.id === window.activeGalleryId);
if (currentGalleryItem && currentGalleryItem.visible === false) {
console.log('toggleEditMode (Edit): Active item was invisible, reloading it.');
if (currentGalleryItem.isPage && window.loadPage) {
window.loadPage(window.activeGalleryId);
} else if(window.loadGallery) {
window.loadGallery(window.activeGalleryId);
}
}
}
if(typeof window.showStyleEditor === 'function') window.showStyleEditor(false); // Ensure style editor is hidden initially
} else {
// Exiting edit mode (going to Live view)
if (sidebarEditControls) {
sidebarEditControls.style.display = 'none';
sidebarEditControls.classList.remove('visible');
}
// Hide logout button when exiting edit mode
const logoutButton = document.getElementById('logoutButton');
if (logoutButton) {
logoutButton.style.display = 'none';
logoutButton.classList.remove('visible');
}
if(typeof window.closeAllForms === 'function') window.closeAllForms();
if (typeof window.destroyNestedSortables === 'function') {
window.destroyNestedSortables();
} else if (window.sortableInstances && Array.isArray(window.sortableInstances)) {
window.sortableInstances.forEach(instance => instance.destroy());
window.sortableInstances = [];
}
// Re-render the menu based on the actual view mode layout.
// These functions should internally handle not showing items with visible:false.
let currentLayout = 'sidebar';
if (window.MenuStyleCustomizer && window.MenuStyleCustomizer.settings) {
currentLayout = window.MenuStyleCustomizer.settings.menuLayout || 'sidebar';
}
if (currentLayout === 'horizontal' && typeof window.renderHorizontalMenu === 'function') {
window.renderHorizontalMenu();
} else if (typeof window.renderGalleries === 'function') {
window.renderGalleries();
}
// Re-render sidebar elements (social, text, etc.) so edit-only UI (grid, dashed slots) is replaced by live view
if (window.SidebarManager && typeof window.SidebarManager._syncWithGlobalEditState === 'function' && typeof window.SidebarManager.renderElements === 'function') {
window.SidebarManager._syncWithGlobalEditState();
window.SidebarManager.renderElements();
}
// Handle the currently active gallery/page.
if (window.galleries && window.activeGalleryId) {
const currentGalleryItem = window.galleries.find(g => g.id === window.activeGalleryId);
if (currentGalleryItem) {
// The content for currentGalleryItem should remain loaded,
// even if currentGalleryItem.visible is false.
// The menu rendering above will hide it from the list if it's invisible.
// Apply page-specific menu hiding rules.
const bodyEl = document.body;
// When exiting edit mode, isInEditMode() will effectively be false for this check.
if (shouldApplyMenuHiddenForEmbedOrGallery(currentGalleryItem)) {
bodyEl.classList.add('menu-hidden-on-page');
} else {
bodyEl.classList.remove('menu-hidden-on-page');
}
// Ensure active states in the (potentially now filtered) menu are updated.
// If the active item is invisible, it won't be marked active in the menu, which is fine.
if(typeof window.updateActiveStates === 'function') window.updateActiveStates();
if(typeof window.updateActiveStatesHorizontal === 'function') window.updateActiveStatesHorizontal();
} else {
// activeGalleryId points to a non-existent item, so clear it and the content.
console.log('toggleEditMode (Live): activeGalleryId points to a non-existent item. Clearing content.');
window.activeGalleryId = null;
const galleryContainer = document.querySelector('.gallery-container');
if (galleryContainer) galleryContainer.innerHTML = '';
if(typeof window.updateActiveStates === 'function') window.updateActiveStates();
if(typeof window.updateActiveStatesHorizontal === 'function') window.updateActiveStatesHorizontal();
}
}
// If, after all that, no content is effectively active (e.g., initial load to root,
// or activeGalleryId became null because the item was deleted)
// then try to load a default page (home page or first visible gallery).
const galleryContainer = document.querySelector('.gallery-container');
// Check if activeGalleryId is null OR if it's set but the container is empty (e.g. item was deleted)
const noActiveContent = window.activeGalleryId === null ||
(galleryContainer && galleryContainer.innerHTML.trim() === '');
if (noActiveContent && window.galleries) {
console.log("toggleEditMode (Live): No active content, attempting to load default page.");
const homePage = window.galleries.find(g => g.isHomePage === true);
let loadedDefault = false;
if (homePage) {
// Load home page regardless of its visibility status for this default loading logic
console.log(`toggleEditMode (Live): Loading home page: ${homePage.title}`);
if (homePage.isPage && window.loadPage) { window.loadPage(homePage.id); loadedDefault = true; }
else if (window.loadGallery) { window.loadGallery(homePage.id); loadedDefault = true; }
}
if (!loadedDefault) {
// If no home page, load the first *visible* gallery/page in live view.
const firstVisibleGallery = window.galleries.find(g => g.visible !== false && !g.isSpacer && !g.isFolder && !g.isSubmenu);
if (firstVisibleGallery) {
console.log(`toggleEditMode (Live): No home page, loading first visible item: ${firstVisibleGallery.title}`);
if (firstVisibleGallery.isPage && window.loadPage) {
window.loadPage(firstVisibleGallery.id);
} else if(window.loadGallery) {
window.loadGallery(firstVisibleGallery.id);
}
} else {
console.log('toggleEditMode (Live): No home page and no visible items to load.');
}
}
}
}
window._editModeToggleInProgress = false;
}
/**
* Improved toggleSidebarElementForm - Closes other forms first
*/
function toggleSidebarElementForm() {
const formId = 'sidebarElementForm';
const form = document.getElementById(formId);
if (!form) return;
// If this form is already open, just close everything
if (window.currentOpenForm === formId) {
closeAllForms();
return;
}
// Otherwise close all forms and open this one
closeAllForms();
// Show the form
form.style.display = 'block';
form.classList.add('visible');
// Set this as the current open form
window.currentOpenForm = formId;
}
// New function to add a sidebar element
function addSidebarElement() {
const typeInput = document.querySelector('input[name="sidebarElementType"]:checked');
if (!typeInput) {
alert('Please select an element type');
return;
}
const type = typeInput.value;
// Use the SidebarManager to add the element
if (window.SidebarManager) {
const newElementId = window.SidebarManager.addElement(type);
toggleSidebarElementForm();
if (newElementId) {
// Save the changes to KV store
saveGalleries().then(() => {
console.log(`New ${type} element added successfully with ID: ${newElementId}`);
}).catch(error => {
console.error('Error saving new element:', error);
});
}
} else {
alert('SidebarManager not available');
}
}
// Global variable to store metadata
window.siteMetadata = {
title: '',
description: '',
googleAnalytics: '',
facebookPixel: '',
noIndex: false,
favicon: '',
ogImageUrl: '',
contentLanguage: 'en', // Default to English
copyrightEnabled: false,
copyrightText: '',
customPublicSiteEnabled: false,
customPublicSiteHtmlR2Key: '',
customPublicSiteHtmlFilename: '',
siteWideBasicAuth: undefined
};
// Global variable to store uploaded favicon URL temporarily
window.uploadedFaviconUrl = '';
function updateMetadataCopyrightFieldState() {
const toggle = document.getElementById('metadataCopyrightEnabled');
const textInput = document.getElementById('metadataCopyrightText');
const isEnabled = !!(toggle && toggle.checked);
if (textInput) {
textInput.disabled = !isEnabled;
}
// Intentionally leave the surrounding form group in place; the disabled attribute on the input
// provides the visual cue in PicoCSS.
}
/**
* Improved toggleMetadataEditor - Closes other forms first
*/
function toggleMetadataEditor() {
const formId = 'metadataEditor';
const editor = document.getElementById(formId);
if (!editor) return;
// If this form is already open, just close everything
if (window.currentOpenForm === formId) {
closeAllForms();
return;
}
// Otherwise close all forms and open this one
closeAllForms();
// Load current values before showing
loadMetadataValues();
editor.style.display = 'block';
// Set this as the current open form
window.currentOpenForm = formId;
}
// Load metadata values into the form
function loadMetadataValues() {
// Get the current values from the global object
const metadata = window.siteMetadata || {};
console.log('loadMetadataValues: Loading metadata:', JSON.stringify(metadata));
console.log('loadMetadataValues: Google Analytics from metadata:', metadata.googleAnalytics);
console.warn('=== FAVICON LOAD DEBUG ===');
console.warn('window.siteMetadata:', JSON.stringify(window.siteMetadata));
console.warn('metadata object:', JSON.stringify(metadata));
console.warn('metadata.favicon value:', metadata.favicon);
console.warn('metadata.favicon type:', typeof metadata.favicon);
// Populate form fields
document.getElementById('metaTitle').value = metadata.title || '';
document.getElementById('metaDescription').value = metadata.description || '';
document.getElementById('googleAnalytics').value = metadata.googleAnalytics || '';
document.getElementById('facebookPixel').value = metadata.facebookPixel || '';
document.getElementById('noIndexToggle').checked = metadata.noIndex || false;
// Schema / Identity fields
const schemaType = metadata.schemaType || '';
const personRadio = document.getElementById('schemaTypePerson');
const orgRadio = document.getElementById('schemaTypeOrganization');
if (personRadio) personRadio.checked = schemaType === 'Person';
if (orgRadio) orgRadio.checked = schemaType === 'Organization';
const jobTitleEl = document.getElementById('schemaJobTitle');
if (jobTitleEl) jobTitleEl.value = metadata.schemaJobTitle || '';
const cityEl = document.getElementById('schemaCity');
if (cityEl) cityEl.value = metadata.schemaCity || '';
const emailEl = document.getElementById('schemaEmail');
if (emailEl) emailEl.value = metadata.schemaEmail || '';
if (typeof updateSchemaFields === 'function') updateSchemaFields();
const copyrightToggle = document.getElementById('metadataCopyrightEnabled');
const copyrightInput = document.getElementById('metadataCopyrightText');
if (copyrightToggle) {
const enabled = !!metadata.copyrightEnabled;
copyrightToggle.checked = enabled;
if (copyrightInput) {
copyrightInput.value = metadata.copyrightText || '';
}
}
updateMetadataCopyrightFieldState();
const customPubToggle = document.getElementById('customPublicSiteEnabled');
if (customPubToggle) {
customPubToggle.checked = !!metadata.customPublicSiteEnabled;
}
const customPubKeyEl = document.getElementById('customPublicSiteHtmlR2Key');
if (customPubKeyEl) {
customPubKeyEl.value = metadata.customPublicSiteHtmlR2Key || '';
}
const customPubFileLabel = document.getElementById('customPublicSiteHtmlFilenameLabel');
if (customPubFileLabel) {
customPubFileLabel.textContent = metadata.customPublicSiteHtmlFilename
? ('Current file: ' + metadata.customPublicSiteHtmlFilename)
: 'No HTML file uploaded yet.';
}
const swAuth = metadata.siteWideBasicAuth;
const swEnabled = !!(swAuth && swAuth.username && swAuth.password);
const swEnableEl = document.getElementById('siteWideBasicAuthEnabled');
if (swEnableEl) swEnableEl.checked = swEnabled;
const swUserEl = document.getElementById('siteWideBasicAuthUsername');
if (swUserEl) swUserEl.value = swAuth && swAuth.username ? swAuth.username : '';
const swPassEl = document.getElementById('siteWideBasicAuthPassword');
if (swPassEl) swPassEl.value = swAuth && swAuth.password ? swAuth.password : '';
const swRealmEl = document.getElementById('siteWideBasicAuthRealm');
if (swRealmEl) swRealmEl.value = swAuth && swAuth.realm ? swAuth.realm : '';
if (typeof updateSiteWideBasicAuthFieldState === 'function') updateSiteWideBasicAuthFieldState();
// Set content language dropdown (defaults to 'en')
const contentLanguageSelect = document.getElementById('contentLanguage');
if (contentLanguageSelect) {
contentLanguageSelect.value = metadata.contentLanguage || 'en';
console.log('Loaded content language:', contentLanguageSelect.value);
}
const faviconInput = document.getElementById('faviconUrl');
if (faviconInput) {
// Check if we have a recently uploaded favicon URL
const faviconValue = metadata.favicon || window.uploadedFaviconUrl || '';
faviconInput.value = faviconValue;
console.warn('Set favicon input value to:', faviconInput.value);
console.warn('Used global uploaded favicon URL:', window.uploadedFaviconUrl);
} else {
console.error('Favicon input element not found!');
}
// Show favicon preview if there's an existing favicon
if (metadata.favicon) {
console.warn('Showing favicon preview for:', metadata.favicon);
showFaviconPreview(metadata.favicon);
} else {
console.warn('No favicon to preview');
}
const ogInput = document.getElementById('ogImageUrl');
if (ogInput) {
const ogVal = (metadata.ogImageUrl || '').trim();
ogInput.value = ogVal;
}
const ogBtnText = document.getElementById('uploadOgImageBtnText');
if (ogBtnText) {
ogBtnText.textContent = (metadata.ogImageUrl || '').trim() ? 'Change image' : 'Upload image';
}
if ((metadata.ogImageUrl || '').trim()) {
showOgImagePreview(metadata.ogImageUrl.trim());
} else {
const ogCont = document.getElementById('ogImagePreviewContainer');
if (ogCont) ogCont.innerHTML = '';
}
console.log('loadMetadataValues: Google Analytics field value after setting:', document.getElementById('googleAnalytics').value);
}
// Save metadata values
function saveMetadata() {
// Get values from form
const googleAnalyticsElement = document.getElementById('googleAnalytics');
const facebookPixelElement = document.getElementById('facebookPixel');
console.log('Google Analytics element found:', !!googleAnalyticsElement);
console.log('Google Analytics element value:', googleAnalyticsElement ? googleAnalyticsElement.value : 'ELEMENT NOT FOUND');
const faviconInput = document.getElementById('faviconUrl');
const faviconValue = faviconInput ? faviconInput.value : '';
const ogImageInput = document.getElementById('ogImageUrl');
const ogImageValue = ogImageInput ? (ogImageInput.value || '').trim() : '';
const contentLanguageSelect = document.getElementById('contentLanguage');
const contentLanguageValue = contentLanguageSelect ? contentLanguageSelect.value : 'en';
const copyrightToggle = document.getElementById('metadataCopyrightEnabled');
const copyrightInput = document.getElementById('metadataCopyrightText');
const copyrightEnabled = copyrightToggle ? copyrightToggle.checked : false;
const copyrightText = copyrightInput ? copyrightInput.value.trim() : '';
console.warn('=== FAVICON SAVE DEBUG ===');
console.warn('Favicon input element exists:', !!faviconInput);
console.warn('Favicon input value:', faviconValue);
console.warn('Favicon input value length:', faviconValue.length);
console.warn('=== CONTENT LANGUAGE SAVE ===');
console.warn('Content language selected:', contentLanguageValue);
const rawGa = googleAnalyticsElement ? googleAnalyticsElement.value : '';
// Normalize GA to ID only (client may not have normalizeGoogleAnalyticsId in scope)
let gaId = (rawGa && typeof rawGa === 'string') ? rawGa.trim() : '';
if (gaId && !/^G-[A-Z0-9]+$/i.test(gaId) && !/^UA-\d+-\d+$/.test(gaId)) {
const match = gaId.match(/(G-[A-Z0-9]+|UA-\d+-\d+)/i);
gaId = match ? match[1] : '';
}
const rawFb = facebookPixelElement ? facebookPixelElement.value : '';
let fbId = (rawFb && typeof rawFb === 'string') ? rawFb.trim() : '';
if (fbId && !/^\d{5,20}$/.test(fbId)) {
const initMatch = fbId.match(/fbq\s*\(\s*['"]init['"]\s*,\s*['"](\d{5,20})['"]/i);
const urlMatch = fbId.match(/facebook\.com\/tr\/?\?(?:[^#\s]*&)?id=(\d{5,20})(?:[&#\s'"]|$)/i);
fbId = initMatch ? initMatch[1] : (urlMatch ? urlMatch[1] : '');
}
const customPubKeyInput = document.getElementById('customPublicSiteHtmlR2Key');
const customPubFilename = document.getElementById('customPublicSiteHtmlFilenameStore');
const schemaPersonRadio = document.getElementById('schemaTypePerson');
const schemaOrgRadio = document.getElementById('schemaTypeOrganization');
const schemaType = schemaPersonRadio && schemaPersonRadio.checked ? 'Person'
: schemaOrgRadio && schemaOrgRadio.checked ? 'Organization'
: '';
const schemaJobTitleEl = document.getElementById('schemaJobTitle');
const schemaCityEl = document.getElementById('schemaCity');
const schemaEmailEl = document.getElementById('schemaEmail');
const siteWideBasicAuthEnabled = !!(document.getElementById('siteWideBasicAuthEnabled') && document.getElementById('siteWideBasicAuthEnabled').checked);
const swUser = document.getElementById('siteWideBasicAuthUsername') ? document.getElementById('siteWideBasicAuthUsername').value.trim() : '';
const swPassEl = document.getElementById('siteWideBasicAuthPassword');
const swPass = swPassEl ? swPassEl.value : '';
const swRealm = document.getElementById('siteWideBasicAuthRealm') ? document.getElementById('siteWideBasicAuthRealm').value.trim() : '';
if (siteWideBasicAuthEnabled && (!swUser || !swPass)) {
alert('Site-wide password: enter both username and password, or turn off the option.');
return;
}
const metadata = {
title: document.getElementById('metaTitle').value,
description: document.getElementById('metaDescription').value,
googleAnalytics: gaId,
facebookPixel: fbId,
noIndex: document.getElementById('noIndexToggle').checked,
favicon: faviconValue,
ogImageUrl: ogImageValue,
contentLanguage: contentLanguageValue,
copyrightEnabled,
copyrightText,
customPublicSiteEnabled: !!(document.getElementById('customPublicSiteEnabled') && document.getElementById('customPublicSiteEnabled').checked),
customPublicSiteHtmlR2Key: customPubKeyInput ? customPubKeyInput.value.trim() : '',
customPublicSiteHtmlFilename: customPubFilename ? customPubFilename.value.trim() : '',
schemaType: schemaType || undefined,
schemaJobTitle: schemaJobTitleEl ? schemaJobTitleEl.value.trim() : '',
schemaCity: schemaCityEl ? schemaCityEl.value.trim() : '',
schemaEmail: schemaEmailEl ? schemaEmailEl.value.trim() : ''
};
if (siteWideBasicAuthEnabled && swUser && swPass) {
metadata.siteWideBasicAuth = {
username: swUser,
password: swPass,
...(swRealm ? { realm: swRealm } : {})
};
}
console.warn('Metadata object being created:', JSON.stringify(metadata));
console.warn('Favicon in metadata object:', metadata.favicon);
// Show normalized GA ID in the form so user sees what was saved (e.g. after pasting full snippet)
if (googleAnalyticsElement && metadata.googleAnalytics !== rawGa.trim()) {
googleAnalyticsElement.value = metadata.googleAnalytics;
}
if (facebookPixelElement && metadata.facebookPixel !== rawFb.trim()) {
facebookPixelElement.value = metadata.facebookPixel;
}
// Update global metadata object immediately
window.siteMetadata = metadata;
console.log("Updated window.siteMetadata:", JSON.stringify(window.siteMetadata));
if (window.SidebarManager && typeof window.SidebarManager.updateMetadataFooter === 'function') {
window.SidebarManager.updateMetadataFooter();
}
// Ensure we have the latest galleries, sidebar elements, and styles
// before constructing the customData object.
// Synchronize galleries first if the function exists
if (typeof synchronizeGalleries === 'function') {
synchronizeGalleries();
console.log('Galleries synchronized within saveMetadata');
}
// Retrieve current galleries (use window.galleries as the source of truth after sync)
const currentGalleries = window.galleries || galleries || [];
console.log(`Current galleries count before constructing customData: ${currentGalleries.length}`);
// Retrieve current sidebar elements
const currentSidebarElements = window.SidebarManager ? window.SidebarManager.elements : [];
// Retrieve current menu styles
const currentMenuStyles = window.MenuStyleCustomizer ? window.MenuStyleCustomizer.settings : {};
// Now construct customData using the retrieved current state
if (window.saveGalleries) {
const customData = {
galleries: currentGalleries, // Use the synchronized/retrieved galleries
siteMetadata: metadata, // Use the metadata gathered from the form
sidebarElements: currentSidebarElements, // Use the retrieved sidebar elements
menuStyles: currentMenuStyles // Use the retrieved menu styles
};
console.log(`Saving metadata with preserved galleries: ${customData.galleries.length}`);
console.log("Metadata being saved:", JSON.stringify(metadata));
console.log("Google Analytics specifically:", metadata.googleAnalytics);
console.log("Sidebar elements being saved:", customData.sidebarElements.length);
console.log("Menu styles being saved:", JSON.stringify(customData.menuStyles));
// Save with our correctly constructed custom data object
window.saveGalleries(customData)
.then(() => {
alert('Metadata saved successfully');
toggleMetadataEditor(); // Hide the editor
})
.catch(error => {
console.error('Error saving metadata:', error);
alert('Error saving metadata. Please try again.');
});
} else {
alert('Save function not available');
}
}
// Function to add new entries to the style editor
function showStyleEditor(display = false) {
if (display && window.MenuStyleCustomizer && typeof window.MenuStyleCustomizer.openOverlay === 'function') {
window.MenuStyleCustomizer.openOverlay();
window.currentOpenForm = 'menuStyleEditor';
return;
}
hideStyleEditor();
}
// New function to hide the style editor
function hideStyleEditor() {
if (window.MenuStyleCustomizer && typeof window.MenuStyleCustomizer.closeOverlay === 'function') {
window.MenuStyleCustomizer.closeOverlay();
}
const styleEditor = document.getElementById('menuStyleEditor');
if (styleEditor) {
styleEditor.style.display = 'none';
}
}
/**
* Improved toggleAddForm - Closes other forms first and ensures proper sizing
*/
function toggleAddForm() {
const formId = 'addForm';
const form = document.getElementById(formId);
if (!form) return;
// If this form is already open, just close everything
if (window.currentOpenForm === formId) {
closeAllForms();
return;
}
// Otherwise close all forms and open this one
closeAllForms();
// Determine the appropriate max-height based on content
// First check if we're showing the page form by checking the radio button
const isPageSelected = document.querySelector('input[name="galleryType"][value="page"]')?.checked;
// Allow extra space for the page form
const maxHeight = isPageSelected ? '600px' : '500px';
// Show the form with proper max height
form.style.maxHeight = maxHeight;
form.style.overflow = 'visible'; // Ensure all content is visible
form.classList.add('visible');
// Also add inline styles to ensure visibility during animation
form.style.padding = '15px';
// Force recalculation of max-height after UI is visible
setTimeout(() => {
// Get actual content height + padding
const contentHeight = form.scrollHeight + 30; // Add padding
// Set max-height based on content with minimum of 500px
form.style.maxHeight = Math.max(contentHeight, 500) + 'px';
console.log(`Setting add form max height to ${form.style.maxHeight}`);
}, 50);
// Populate parent options
const parentSelect = document.getElementById('galleryParent');
if (parentSelect) {
parentSelect.innerHTML = '<option value="">None (Top Level)</option>';
// Add all galleries that could be parents (including submenus)
galleries.forEach(gallery => {
parentSelect.innerHTML += `<option value="${gallery.id}">${gallery.title}</option>`;
});
}
// Set this as the current open form
window.currentOpenForm = formId;
}
// Store the currently open form ID
window.currentOpenForm = null;
/**
* Close a child panel opened on top of the Style Customizer (palette explorer, custom code, color mixer).
* Returns true when a child was closed so callers can keep the Style Customizer open.
*/
function closeTopmostStyleCustomizerChildOverlay() {
if (document.querySelector('.pe-palette-explorer-overlay')) {
if (window.PaletteExplorer && typeof window.PaletteExplorer.close === 'function') {
const wasOpen = !!document.querySelector('.pe-palette-explorer-overlay');
window.PaletteExplorer.close();
if (wasOpen && document.querySelector('.pe-palette-explorer-overlay')) {
return false;
}
return wasOpen;
}
}
if (document.querySelector('.pe-style-history-overlay, .sc-style-history-overlay') &&
window.MenuStyleCustomizer &&
typeof window.MenuStyleCustomizer.closeStyleHistoryOverlay === 'function') {
window.MenuStyleCustomizer.closeStyleHistoryOverlay();
return true;
}
if (document.querySelector('.pe-font-explorer-overlay') &&
window.MenuStyleCustomizer &&
typeof window.MenuStyleCustomizer.closeFontExplorer === 'function') {
window.MenuStyleCustomizer.closeFontExplorer();
return true;
}
if (document.querySelector('.pe-custom-code-overlay') &&
window.MenuStyleCustomizer &&
typeof window.MenuStyleCustomizer.closeCustomCodeOverlay === 'function') {
window.MenuStyleCustomizer.closeCustomCodeOverlay();
return true;
}
const colorMixer = document.getElementById('colorMixerPopup');
if (colorMixer && colorMixer.classList.contains('is-open')) {
colorMixer.classList.remove('is-open');
return true;
}
return false;
}
/**
* Close all editor forms with improved handling for add form
*/
function closeAllForms() {
if (typeof closeTopmostStyleCustomizerChildOverlay === 'function') {
while (closeTopmostStyleCustomizerChildOverlay()) {
// Close nested Style Customizer panels (palette, custom code, color mixer) first.
}
}
if (window.MenuStyleCustomizer && typeof window.MenuStyleCustomizer.closeOverlay === 'function') {
const styleOverlayWasOpen = window.MenuStyleCustomizer._overlayOpen;
window.MenuStyleCustomizer.closeOverlay();
if (styleOverlayWasOpen && window.MenuStyleCustomizer._overlayOpen) {
return;
}
}
// Add Form - Special handling to ensure proper animation
const addForm = document.getElementById('addForm');
if (addForm) {
addForm.classList.remove('visible');
// Also force style reset after animation completes
setTimeout(() => {
if (!addForm.classList.contains('visible')) {
addForm.style.maxHeight = '0';
addForm.style.overflow = 'hidden';
addForm.style.padding = '0 15px';
}
}, 350); // Set slightly longer than CSS transition
}
// Style Editor
const styleEditor = document.getElementById('menuStyleEditor');
if (styleEditor) {
styleEditor.style.display = 'none';
}
// Metadata Editor
const metadataEditor = document.getElementById('metadataEditor');
if (metadataEditor) {
metadataEditor.style.display = 'none';
}
// Sidebar Element Form
const sidebarElementForm = document.getElementById('sidebarElementForm');
if (sidebarElementForm) {
sidebarElementForm.style.display = 'none';
sidebarElementForm.classList.remove('visible');
}
// Import Classic Form
const importClassicForm = document.getElementById('importClassicForm');
if (importClassicForm) {
importClassicForm.style.display = 'none';
importClassicForm.classList.remove('visible');
}
// Clear the current open form
window.currentOpenForm = null;
}
function downloadJson() {
const data = JSON.stringify(galleries, null, 2);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'galleries.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
let galleriesBackup = [];
/**
* Improved version of initializeNestedSortables with a better nesting approach
*/
function initializeNestedSortables() {
console.log('Initializing nested sortables with improved nesting logic...');
// Create a backup of galleries
try {
galleriesBackup = JSON.parse(JSON.stringify(galleries));
} catch (error) {
console.error('Error creating galleries backup:', error);
galleriesBackup = [];
}
// Find all nested sortable lists
const nestedSortables = document.querySelectorAll('.nested-sortable');
if (nestedSortables.length === 0) {
console.warn('No nested sortable elements found! Check your HTML structure.');
return;
}
// Helper: compute depth based on ancestor nested lists
function computeDepth(liEl) {
let depth = 0;
let node = liEl;
while (node) {
const parentOl = node.closest('ol.nested-sortable');
if (!parentOl) break;
const parentLi = parentOl.closest('li');
if (parentLi) {
depth += 1;
node = parentLi;
} else {
break;
}
}
// Top-level depth should be 0
return Math.max(depth - 1, 0);
}
// Helper: apply indentation styling to an li and its subtree
function applyIndentationFromDepth(rootLi) {
if (!rootLi) return;
const applyToItem = (li) => {
const depth = computeDepth(li);
// Update data-nesting-level attribute
li.setAttribute('data-nesting-level', depth);
// Apply padding-left style
const content = li.querySelector('.gallery-item-content');
if (content) {
content.style.paddingLeft = (depth * 16) + 'px';
}
};
applyToItem(rootLi);
rootLi.querySelectorAll('li').forEach(applyToItem);
}
// Helper: ensure parent has proper state and nested list
function ensureParentChildState(parentLi) {
if (!parentLi) return null;
parentLi.classList.add('has-children');
let nestedList = parentLi.querySelector('ol.nested-sortable');
if (!nestedList) {
nestedList = document.createElement('ol');
nestedList.className = 'nested-sortable';
parentLi.appendChild(nestedList);
}
return nestedList;
}
// Helper: clean parent if it has no children
function cleanupParentIfEmpty(parentLi) {
if (!parentLi) return;
const nestedList = parentLi.querySelector('ol.nested-sortable');
if (nestedList && nestedList.children.length === 0) {
nestedList.remove();
parentLi.classList.remove('has-children');
}
}
// Initialize each sortable with improved nesting settings
let sortableInstances = [];
for (let i = 0; i < nestedSortables.length; i++) {
try {
const container = nestedSortables[i];
// Track where the placeholder indicates the item should be placed
// This helps us detect when SortableJS incorrectly auto-nests based on mouse position
let intendedPlacementContainer = null;
let intendedRelatedElement = null;
// Track last move to prevent oscillation
let lastMoveTime = 0;
let lastMoveRelatedId = null;
const MOVE_DEBOUNCE_MS = 50; // Minimum time between moves (in milliseconds)
// Track if we're dragging the last item in a folder (to prevent boundary oscillation)
let isLastItemInFolder = false;
let draggedItemOriginalContainer = null;
// Track container changes to prevent rapid switching (hysteresis)
let firstPositionFixLoopActive = false; // Prevent multiple fix loops
let lastContainerDecision = null; // 'inside' or 'outside'
let lastContainerDecisionTime = 0;
const CONTAINER_SWITCH_DEBOUNCE_MS = 150; // Longer debounce for container switches
const sortable = new Sortable(container, {
group: 'nested',
animation: 150, // Reduced animation during drag to reduce lag
easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)', // Smooth easing function
fallbackOnBody: false, // Disable forced fallback on desktop for better performance
forceFallback: false, // Use native drag on desktop
// CRITICAL: These settings make nesting much easier
// Higher swap threshold makes it easier to drag over items
swapThreshold: 0.65, // Increased to reduce oscillation within same folder
// IMPROVED NESTING SETTINGS
// Used when determining if an item should be nested
invertSwap: false, // Disable to reduce oscillation
direction: 'vertical', // Lock direction to vertical to reduce oscillation
// Use handle for dragging
handle: '.nested-sortable-handle',
// Filter out only the toggle button - this prevents SortableJS from intercepting clicks on it
// The folder text (.menu-item.folder) is NOT filtered, so it can be used for both:
// - Quick click → toggles folder (onclick handler fires before drag starts)
// - Click and drag → drags the item (SortableJS handles the drag)
// The delay helps distinguish between click and drag, but on desktop with delayOnTouchOnly: true,
// the onclick should fire quickly enough before SortableJS intercepts
filter: '.submenu-toggle, .submenu-toggle *, .toggle-icon',
preventOnFilter: true, // Prevent drag when clicking filtered elements (toggle button only)
// Distinguish between click and drag
delay: 120,
delayOnTouchOnly: true,
// Empty container settings
emptyInsertThreshold: 10,
// Add custom ghost class
ghostClass: 'sortable-ghost',
chosenClass: 'sortable-chosen',
dragClass: 'sortable-drag',
// Make sure we listen to external move events
dragoverBubble: true,
// Better drag preview stability
scroll: true,
scrollSensitivity: 100,
scrollSpeed: 20,
// Reduce oscillation by making placeholder movement less sensitive
forceFallback: false, // Use native drag for better performance
fallbackTolerance: 0, // No tolerance - use exact position
/**
* NESTING LOGIC: Prevent auto-nesting based on hover
* Instead, rely on SortableJS's placeholder position which is accurate
* We'll verify and correct placement in onEnd based on actual DOM position
*/
onMove: function(evt, originalEvent) {
const dragged = evt.dragged;
const related = evt.related;
if (!dragged || !related) return true;
// Safety check: Ensure items have data-id attributes
// This can be missing if item was just added and DOM not fully updated
if (!dragged.dataset.id || !related.dataset.id) {
console.warn('⚠️ [Drag] Item missing data-id attribute, aborting drag', {
draggedHasId: !!dragged.dataset.id,
relatedHasId: !!related.dataset.id
});
return false;
}
// Get the IDs to check for circular references
const draggedId = parseInt(dragged.dataset.id);
const relatedId = parseInt(related.dataset.id);
// Don't allow dropping inside itself or its descendants
if (draggedId === relatedId || hasAncestor(relatedId, draggedId)) {
return false;
}
const currentTime = Date.now();
const rootContainer = document.querySelector('#galleryTree > ol.nested-sortable');
const draggedParent = draggedItemOriginalContainer || dragged.parentElement;
// Determine if we're moving inside or outside a folder
// IMPORTANT: Check if related element IS the folder item itself (parent li)
// When targeting first position, SortableJS might use the folder item as related
const isRelatedFolderItem = related.classList && related.classList.contains('has-children');
// CRITICAL: Handle empty folder case FIRST - before any other logic
// When dragging into an empty folder, related is the folder item itself
// This is essentially the same as targeting first position, but for an empty folder
// We need to allow this immediately, bypassing all debounce and boundary checks
if (isRelatedFolderItem) {
const folderNestedSortable = related.querySelector('ol.nested-sortable');
if (folderNestedSortable && folderNestedSortable.children.length === 0) {
// Empty folder detected - this is a drop into empty folder (first position)
// Always allow it, bypass all debounce and boundary logic
lastMoveTime = currentTime;
lastMoveRelatedId = relatedId;
intendedPlacementContainer = folderNestedSortable;
intendedRelatedElement = null; // No related element in empty folder
// Mark as moving inside folder for tracking
lastContainerDecision = 'inside';
lastContainerDecisionTime = currentTime;
// CRITICAL: Manually move placeholder into empty folder's nested-sortable
// SortableJS might not do this automatically for empty containers
requestAnimationFrame(() => {
const placeholder = document.querySelector('.sortable-ghost');
if (placeholder && placeholder.parentElement !== folderNestedSortable) {
// Move placeholder into the empty folder
folderNestedSortable.appendChild(placeholder);
}
// Update ghost indentation for empty folder
const ghost = document.querySelector('.sortable-ghost');
if (ghost) {
const ghostContent = ghost.querySelector('.gallery-item-content');
if (ghostContent) {
// Calculate depth from parent folder
const parentFolder = folderNestedSortable.closest('li.has-children');
if (parentFolder) {
const targetDepth = computeDepth(parentFolder) + 1;
ghostContent.style.paddingLeft = (targetDepth * 16) + 'px';
}
}
}
});
// Return true immediately to allow the move
return true;
}
}
let actualRelatedParent = related.parentElement;
// If related IS the folder item, we need to get its nested sortable container
if (isRelatedFolderItem) {
const nestedSortable = related.querySelector('ol.nested-sortable');
if (nestedSortable) {
actualRelatedParent = nestedSortable;
}
}
const relatedParent = actualRelatedParent;
const isMovingInsideFolder = relatedParent &&
relatedParent.classList.contains('nested-sortable') &&
relatedParent !== rootContainer;
const isMovingOutsideFolder = !isRelatedFolderItem && relatedParent === rootContainer;
const currentDecision = isMovingInsideFolder ? 'inside' : (isMovingOutsideFolder ? 'outside' : null);
// Determine if we're moving within the same folder - this is the key check
// If related IS the folder item, we're still moving within the same folder
let isMovingWithinSameFolder = (isMovingInsideFolder &&
draggedParent &&
draggedParent.classList.contains('nested-sortable') &&
draggedParent !== rootContainer &&
relatedParent === draggedParent) ||
// OR: if related is the folder item itself and we're dragging from that folder
(isRelatedFolderItem &&
draggedParent &&
draggedParent.classList.contains('nested-sortable') &&
draggedParent !== rootContainer &&
related.querySelector('ol.nested-sortable') === draggedParent);
// Special case: If related is the folder item, we're definitely trying to move within that folder
// This happens when targeting first position - SortableJS uses the folder item as related
if (isRelatedFolderItem && draggedParent && draggedParent.classList.contains('nested-sortable') && draggedParent !== rootContainer) {
const folderNestedSortable = related.querySelector('ol.nested-sortable');
if (folderNestedSortable === draggedParent) {
// We're dragging within the folder, and related is the folder item
// This means we're targeting first position - FORCE it to be treated as same-folder move
// FORCE same-folder detection - override any previous calculation
isMovingWithinSameFolder = true;
}
}
// DEBUG: Log placeholder position changes ONLY when dragging from within a folder AND something interesting happens
// (Don't log every single move - too noisy)
if (draggedParent && draggedParent.classList.contains('nested-sortable') && draggedParent !== rootContainer) {
const folderItems = Array.from(draggedParent.children);
const draggedIndex = folderItems.indexOf(dragged);
const relatedIndex = folderItems.indexOf(related);
// Get placeholder element to see where it actually is
const placeholder = document.querySelector('.sortable-ghost');
const placeholderParent = placeholder ? placeholder.parentElement : null;
const placeholderIndex = placeholderParent && placeholderParent.classList.contains('nested-sortable')
? Array.from(placeholderParent.children).indexOf(placeholder)
: -1;
const folderItem = draggedParent.closest('li.has-children');
const folderItemId = folderItem ? folderItem.dataset.id : null;
const firstItemInFolder = folderItems.length > 0 ? folderItems[0] : null;
const firstItemInFolderId = firstItemInFolder ? firstItemInFolder.dataset.id : null;
// Check if we're trying to target first position
// related could be the first item OR the folder item itself (when SortableJS uses folder as related)
const isFirstPosition = folderItems.length > 0 && related === firstItemInFolder;
const isRelatedFolderItemCheck = related === folderItem;
const isTargetingFirstViaFolderItem = isRelatedFolderItemCheck && !!folderItem; // FIXED: was assigning folderItem instead of boolean
// When targeting first via folder item, placeholder should be at index 0
// But we're seeing it at index 1 - this is the issue!
const shouldBeAtFirstPosition = isTargetingFirstViaFolderItem && placeholderIndex === 1 && draggedIndex > 0;
// Check if placeholder is outside the folder when it should be inside
const placeholderIsOutside = placeholderParent === rootContainer || (placeholderParent && !placeholderParent.classList.contains('nested-sortable'));
const shouldBeInside = draggedIndex >= 0; // We're dragging from inside
// Only log when there's a potential issue or interesting state change
const placeholderMismatch = placeholderIndex !== relatedIndex && placeholderIndex >= 0 && relatedIndex >= 0;
const jumpingOut = !isMovingWithinSameFolder && draggedIndex >= 0 && relatedIndex < 0;
const jumpingOutToRoot = placeholderIsOutside && shouldBeInside;
// First position issue: trying to target first but:
// - placeholder is outside OR
// - placeholder is at index 1 instead of 0 (when targeting first via folder item)
const firstPositionIssue = (isFirstPosition || isTargetingFirstViaFolderItem) && (jumpingOut || jumpingOutToRoot || placeholderIsOutside || shouldBeAtFirstPosition);
// ALWAYS log when targeting first position via folder item (this is the key case we're debugging)
// OR when placeholder jumps out when it should stay in
// OR when placeholder should be at first position but isn't
const shouldLog = placeholderMismatch || jumpingOut || jumpingOutToRoot || firstPositionIssue || isTargetingFirstViaFolderItem || shouldBeAtFirstPosition;
if (shouldLog) {
console.error('🔍 [Placeholder Position]', {
draggedId,
draggedIndex,
relatedId,
relatedIndex,
placeholderIndex,
relatedElementTag: related.tagName,
isRelatedFolderItem: isRelatedFolderItemCheck,
isTargetingFirstViaFolderItem,
isMovingWithinSameFolder,
placeholderMismatch,
jumpingOut,
jumpingOutToRoot,
isFirstPosition,
placeholderIsOutside,
placeholderParentType: placeholderParent === rootContainer ? 'ROOT' : (placeholderParent && placeholderParent.classList.contains('nested-sortable') ? 'FOLDER' : 'OTHER'),
folderItemId,
firstItemInFolderId,
firstPositionIssue,
shouldBeAtFirstPosition,
expectedPlaceholderIndex: isTargetingFirstViaFolderItem ? 0 : placeholderIndex
});
}
}
// CRITICAL: For moves within the same folder, skip ALL the complex boundary logic
// This allows smooth reordering just like items not in folders
if (isMovingWithinSameFolder) {
// Simple debounce only - same as root level items
const timeSinceLastMove = currentTime - lastMoveTime;
// Only prevent very rapid oscillation to same element
if (timeSinceLastMove < MOVE_DEBOUNCE_MS * 0.5 && relatedId === lastMoveRelatedId) {
return false;
}
// Allow all moves within same folder - update tracking and continue
// SPECIAL FIX: When targeting first position via folder item, fix placeholder position
// SortableJS places it at index 1 when related is the folder item, but we want it at index 0
// BUT: Only apply this fix when actually near/at first position to avoid being too aggressive
if (isRelatedFolderItem && draggedParent && draggedParent.classList.contains('nested-sortable') && draggedParent !== rootContainer) {
const folderItems = Array.from(draggedParent.children);
const firstItem = folderItems.length > 0 ? folderItems[0] : null;
// Only apply fix if we're actually near first position (draggedIndex is small, indicating we're moving up)
// This prevents the fix from being too aggressive when moving to other positions
const isNearFirstPosition = draggedIndex <= 2; // Allow fix when within first 3 positions
if (firstItem && firstItem !== dragged && isNearFirstPosition) {
// Fix immediately (synchronously) first - don't wait for next frame
const placeholder = document.querySelector('.sortable-ghost');
if (placeholder && placeholder.parentElement === draggedParent) {
const currentIndex = Array.from(draggedParent.children).indexOf(placeholder);
// Only fix if placeholder is at index 1 when we're targeting first position
// Don't force it if it's at index 0 or beyond index 2 (user might be moving to position 2 or 3)
if (currentIndex === 1 && draggedIndex <= 1) {
draggedParent.insertBefore(placeholder, firstItem);
}
}
} else {
// Not near first position - stop any active fix loop
firstPositionFixLoopActive = false;
}
} else {
// Not targeting first position anymore - reset flag
firstPositionFixLoopActive = false;
}
lastMoveTime = currentTime;
lastMoveRelatedId = relatedId;
// Skip all the boundary case logic below - just continue to allow the move
// Fall through to update intendedPlacementContainer and return true
} else {
// Only apply complex boundary logic for moves BETWEEN folders or to/from root
// Hysteresis: Prevent rapid switching between inside/outside folder
const targetFolderItems = isMovingInsideFolder ? Array.from(relatedParent.children) : [];
const isFirstPositionInFolder = isMovingInsideFolder &&
targetFolderItems.length > 0 &&
related === targetFolderItems[0];
// Special case: Empty folder - allow easy dropping into it
const isEmptyFolder = isMovingInsideFolder && targetFolderItems.length === 0;
if (currentDecision && lastContainerDecision && currentDecision !== lastContainerDecision) {
const timeSinceContainerSwitch = currentTime - lastContainerDecisionTime;
// Make first position easier to target from outside
if (isFirstPositionInFolder) {
// Moving to first position from outside - use shorter debounce
const effectiveDebounce = CONTAINER_SWITCH_DEBOUNCE_MS * 0.3;
if (timeSinceContainerSwitch < effectiveDebounce) {
// Only block if it's very rapid (oscillation)
if (timeSinceContainerSwitch < MOVE_DEBOUNCE_MS) {
return false;
}
}
} else if (isEmptyFolder) {
// Empty folder - allow easy dropping, minimal debounce
// Only block very rapid oscillation
if (timeSinceContainerSwitch < MOVE_DEBOUNCE_MS * 0.2) {
return false;
}
} else {
// Normal container switch - apply full hysteresis
if (timeSinceContainerSwitch < CONTAINER_SWITCH_DEBOUNCE_MS) {
return false;
}
}
}
}
// Special handling for boundary cases (last item in folder, or dragging into folder)
// BUT: Skip all this logic if we're moving within the same folder (already handled above)
if (!isMovingWithinSameFolder && draggedParent && draggedParent.classList.contains('nested-sortable') && draggedParent !== rootContainer) {
const folderItem = draggedParent.closest('li.has-children');
const currentFolderItems = Array.from(draggedParent.children);
const isFirstPositionInCurrentFolder = currentFolderItems.length > 0 &&
currentFolderItems[0] !== dragged &&
(related === currentFolderItems[0] ||
(isMovingInsideFolder && relatedParent === draggedParent &&
related === currentFolderItems[0]));
// Case 0: This case is now handled earlier for same-folder moves
// Only handle cross-folder moves here
// Case 1: Dragging last item out of folder
if (isLastItemInFolder && isMovingOutsideFolder) {
const timeSinceLastMove = currentTime - lastMoveTime;
// BUT: If we're trying to move to first position, don't block it
// Check if the related element is the folder itself or right after it
if (folderItem) {
const folderNextSibling = folderItem.nextElementSibling;
// If we're moving to position right after folder, it might be trying to go to first position
// Allow it if it's been a reasonable time
if (folderNextSibling && related === folderNextSibling) {
// This is ambiguous - could be first position or outside
// Require more time but not as much as pure outside move
if (timeSinceLastMove < MOVE_DEBOUNCE_MS * 2) {
if (relatedId === lastMoveRelatedId) {
return false;
}
}
} else {
// Definitely moving outside - require longer debounce
if (timeSinceLastMove < MOVE_DEBOUNCE_MS * 2.5) {
if (relatedId === lastMoveRelatedId) {
return false; // Reject if oscillating
}
}
}
} else {
// No folder item found - apply standard debounce
if (timeSinceLastMove < MOVE_DEBOUNCE_MS * 2.5) {
if (relatedId === lastMoveRelatedId) {
return false;
}
}
}
}
// Case 2: Dragging item into folder from outside
if (isMovingInsideFolder && relatedParent !== draggedParent) {
const targetFolderItems = Array.from(relatedParent.children);
const isEmptyFolder = targetFolderItems.length === 0;
const isNearBottom = targetFolderItems.length > 0 &&
(related === targetFolderItems[targetFolderItems.length - 1] ||
targetFolderItems.indexOf(related) >= targetFolderItems.length - 1);
const isFirstPos = targetFolderItems.length > 0 && related === targetFolderItems[0];
// Special handling for empty folder - allow easy dropping
if (isEmptyFolder) {
const timeSinceLastMove = currentTime - lastMoveTime;
// Very minimal debounce for empty folder - only prevent rapid oscillation
if (timeSinceLastMove < MOVE_DEBOUNCE_MS * 0.2 && relatedId === lastMoveRelatedId) {
return false;
}
// Allow move into empty folder - don't block it
} else if (isFirstPos) {
// Moving to first position - make it easy
const timeSinceLastMove = currentTime - lastMoveTime;
// Very short debounce for first position
if (timeSinceLastMove < MOVE_DEBOUNCE_MS * 0.3 && relatedId === lastMoveRelatedId) {
return false;
}
// Allow move to first position
} else if (isNearBottom) {
// For bottom position, still use some debounce to prevent oscillation
const timeSinceLastMove = currentTime - lastMoveTime;
if (timeSinceLastMove < MOVE_DEBOUNCE_MS * 1.5) {
if (relatedId === lastMoveRelatedId) {
return false;
}
}
}
}
// Case 2.5: Prevent jumping OUT of folder when trying to move to first/last position within
// This is tricky - SortableJS might detect it as outside when we're actually targeting first/last
if (isMovingOutsideFolder && folderItem && draggedParent === draggedItemOriginalContainer) {
// We're dragging from within a folder but detected as moving outside
// Check if the related element is adjacent to the folder (which might indicate targeting first/last)
const folderIndex = Array.from(rootContainer.children).indexOf(folderItem);
const relatedIndex = Array.from(rootContainer.children).indexOf(related);
// If related is immediately before or after folder, might be targeting first/last
if (folderIndex >= 0 && relatedIndex >= 0) {
const isAdjacentToFolder = (relatedIndex === folderIndex - 1) || (relatedIndex === folderIndex + 1);
if (isAdjacentToFolder) {
// Might be targeting first/last position - be lenient
const timeSinceLastMove = currentTime - lastMoveTime;
// Only block if it's very rapid oscillation
if (timeSinceLastMove < MOVE_DEBOUNCE_MS * 0.4 && relatedId === lastMoveRelatedId) {
return false;
}
// Otherwise allow - user might be trying to target first/last position
// Don't block based on this check
}
}
}
}
// Case 3: Dragging from root into folder
if (draggedParent === rootContainer && isMovingInsideFolder) {
const targetFolderItems = Array.from(relatedParent.children);
const isEmptyFolder = targetFolderItems.length === 0;
const isNearBottom = targetFolderItems.length > 0 &&
(related === targetFolderItems[targetFolderItems.length - 1] ||
targetFolderItems.indexOf(related) >= targetFolderItems.length - 1);
const isFirstPosition = targetFolderItems.length > 0 && related === targetFolderItems[0];
// Special handling for empty folder - allow easy dropping
if (isEmptyFolder) {
const timeSinceLastMove = currentTime - lastMoveTime;
// Very minimal debounce for empty folder - only prevent rapid oscillation
if (timeSinceLastMove < MOVE_DEBOUNCE_MS * 0.2) {
if (relatedId === lastMoveRelatedId) {
return false;
}
}
// Allow move into empty folder - don't block it
} else if (isFirstPosition) {
// Special handling for first position - make it easier to target
const timeSinceLastMove = currentTime - lastMoveTime;
// Make first position easier - only prevent very rapid oscillation
if (timeSinceLastMove < MOVE_DEBOUNCE_MS * 0.5) {
if (relatedId === lastMoveRelatedId) {
return false;
}
}
// Allow move to first position - don't block it
// Continue to track but don't apply strict debouncing
} else if (isNearBottom) {
// Keep strict debouncing for bottom position
const timeSinceLastMove = currentTime - lastMoveTime;
if (timeSinceLastMove < MOVE_DEBOUNCE_MS * 2) {
if (relatedId === lastMoveRelatedId) {
return false;
}
}
}
}
// Case 4: Prevent jumping FROM first position to outside folder (but allow easy access TO first position)
// If we're at first position and trying to move outside, require deliberate movement
if (isMovingInsideFolder) {
const currentFolderItems = Array.from(relatedParent.children);
const isFirstPos = currentFolderItems.length > 0 && related === currentFolderItems[0];
// If we're moving FROM first position to outside (reverse hysteresis)
if (isFirstPos && lastContainerDecision === 'outside') {
const timeSinceLastMove = currentTime - lastMoveTime;
// If we just moved outside, require more time before allowing back to first position
// BUT: if it's been a reasonable time, allow it (don't make it too hard)
if (timeSinceLastMove < MOVE_DEBOUNCE_MS * 0.8 && relatedId === lastMoveRelatedId) {
// Very rapid - might be oscillation
return false;
}
}
}
// Case 5: Prevent oscillation between first and second position within folder
// BUT: Only apply if NOT moving within same folder (to allow smooth reordering)
if (isMovingInsideFolder && relatedParent && !isMovingWithinSameFolder) {
const folderItems = Array.from(relatedParent.children);
const isFirstPos = folderItems.length > 0 && related === folderItems[0];
const isSecondPos = folderItems.length > 1 && related === folderItems[1];
// If moving from first to second (or vice versa) very rapidly, prevent oscillation
if ((isFirstPos || isSecondPos) && lastMoveRelatedId) {
const lastRelated = document.querySelector('#galleryTree li[data-id="' + lastMoveRelatedId + '"]') || document.querySelector('li[data-id="' + lastMoveRelatedId + '"]');
if (lastRelated && lastRelated.parentElement === relatedParent) {
const lastRelatedIndex = folderItems.indexOf(lastRelated);
const isOscillating = (isFirstPos && lastRelatedIndex === 1) || (isSecondPos && lastRelatedIndex === 0);
if (isOscillating) {
const timeSinceLastMove = currentTime - lastMoveTime;
// Allow moving to first position easily, but prevent rapid oscillation between first/second
if (isSecondPos && timeSinceLastMove < MOVE_DEBOUNCE_MS * 1.2) {
return false; // Prevent jumping from first to second too easily
}
// But allow first position more easily
if (isFirstPos && timeSinceLastMove < MOVE_DEBOUNCE_MS * 0.6 && relatedId === lastMoveRelatedId) {
return false; // Only prevent very rapid oscillation
}
}
}
}
}
// Prevent rapid oscillation when dragging within same container
// NOTE: For same-folder moves, we already updated tracking above, so skip this section
if (!isMovingWithinSameFolder) {
const timeSinceLastMove = currentTime - lastMoveTime;
// If moving to the same related element very quickly, reject to prevent oscillation
if (timeSinceLastMove < MOVE_DEBOUNCE_MS && relatedId === lastMoveRelatedId) {
return false;
}
// Update tracking variables (only for non-same-folder moves)
lastMoveTime = currentTime;
lastMoveRelatedId = relatedId;
}
// For same-folder moves, tracking was already updated in the earlier block
// Update container decision tracking
if (currentDecision) {
if (currentDecision !== lastContainerDecision) {
lastContainerDecisionTime = currentTime;
}
lastContainerDecision = currentDecision;
}
// Track where the placeholder indicates the item should be placed
// The related element is next to where the placeholder will be positioned
// Check if it's at root level or nested level
if (related) {
intendedRelatedElement = related;
const relatedParent = related.parentElement;
const rootContainer = document.querySelector('#galleryTree > ol.nested-sortable');
// Determine if the placeholder is at root level or nested level
if (relatedParent === rootContainer) {
// Placeholder is at root level (not nested)
intendedPlacementContainer = rootContainer;
} else if (relatedParent && relatedParent.classList.contains('nested-sortable')) {
// Placeholder is in a nested list (inside a folder)
intendedPlacementContainer = relatedParent;
}
// Update ghost element indentation to match the target position
requestAnimationFrame(() => {
const ghost = document.querySelector('.sortable-ghost');
if (ghost && intendedPlacementContainer) {
const ghostContent = ghost.querySelector('.gallery-item-content');
if (ghostContent) {
// Calculate the depth where the ghost will be placed
// Use the related element to determine the depth (if it exists and is in the target container)
let targetDepth = 0;
if (intendedPlacementContainer === rootContainer) {
targetDepth = 0; // Root level
} else if (related && related.parentElement === intendedPlacementContainer) {
// Use the related element's depth to determine target depth
targetDepth = computeDepth(related);
} else {
// Fallback: find first child in the container or calculate from parent folder
const firstChild = intendedPlacementContainer.querySelector('li');
if (firstChild) {
targetDepth = computeDepth(firstChild);
} else {
// Empty container (like empty folder) - calculate from parent folder
const parentFolder = intendedPlacementContainer.closest('li.has-children');
if (parentFolder) {
targetDepth = computeDepth(parentFolder) + 1;
}
}
}
// Special handling: If intendedPlacementContainer is empty and placeholder isn't in it yet,
// ensure placeholder is moved there and indentation is correct
if (intendedPlacementContainer.children.length === 0) {
const placeholder = document.querySelector('.sortable-ghost');
if (placeholder && placeholder.parentElement !== intendedPlacementContainer) {
// Move placeholder into empty container
intendedPlacementContainer.appendChild(placeholder);
}
}
ghostContent.style.paddingLeft = (targetDepth * 16) + 'px';
}
}
});
}
// Let SortableJS handle the placement based on placeholder position
// We'll verify and correct if needed in onEnd
return true; // Allow the move
},
// Capture indentation before drag starts (fires when item is chosen)
onChoose: function(evt) {
if (!evt.item) return;
const draggedItem = evt.item;
const content = draggedItem.querySelector('.gallery-item-content');
// Store the original padding-left for the ghost
if (content) {
const computedStyle = window.getComputedStyle(content);
const originalPaddingLeft = computedStyle.paddingLeft;
draggedItem.dataset.originalPaddingLeft = originalPaddingLeft;
}
},
// Visual feedback on drag start
onStart: function(evt) {
if (!evt.item) return;
// Don't set dimensions on the dragged item - let it maintain natural size
// Setting width/height can cause layout shifts and menu expansion
const draggedItem = evt.item;
// Store original container and check if it's the last item in folder
draggedItemOriginalContainer = draggedItem.parentElement;
const rootContainer = document.querySelector('#galleryTree > ol.nested-sortable');
if (draggedItemOriginalContainer &&
draggedItemOriginalContainer.classList.contains('nested-sortable') &&
draggedItemOriginalContainer !== rootContainer) {
const siblings = Array.from(draggedItemOriginalContainer.children);
isLastItemInFolder = siblings.length > 0 && siblings[siblings.length - 1] === draggedItem;
} else {
isLastItemInFolder = false;
}
// Apply the stored padding-left to the ghost element
// The ghost is created by SortableJS at this point
requestAnimationFrame(() => {
const ghost = document.querySelector('.sortable-ghost');
if (ghost) {
const ghostContent = ghost.querySelector('.gallery-item-content');
const originalPaddingLeft = draggedItem.dataset.originalPaddingLeft;
if (ghostContent && originalPaddingLeft) {
ghostContent.style.paddingLeft = originalPaddingLeft;
}
}
});
// Reset oscillation tracking variables
lastMoveTime = 0;
lastMoveRelatedId = null;
lastContainerDecision = null;
lastContainerDecisionTime = 0;
// Create a backup
try {
window._dragStartElementIds = [];
document.querySelectorAll('.nested-sortable li').forEach(li => {
if (li.dataset.id) {
window._dragStartElementIds.push(li.dataset.id);
}
});
galleriesBackup = JSON.parse(JSON.stringify(galleries));
} catch (error) {
console.error('Error creating backup:', error);
}
// Add indicator classes
document.body.classList.add('menu-item-dragging');
// Add hover indicator class to submenu items
document.querySelectorAll('.has-children').forEach(item => {
item.classList.add('potential-parent');
});
// Prevent layout recalculation during drag
document.body.style.setProperty('--is-dragging', '1');
},
// Cleanup on end
onEnd: function(evt) {
// Verify and correct placement based on placeholder's intended position
// SortableJS may auto-nest incorrectly based on mouse position, even if placeholder shows root level
const dragged = evt.item;
const previousParentLi = evt.from ? evt.from.closest('li') : null;
const rootContainer = document.querySelector('#galleryTree > ol.nested-sortable');
if (!dragged || !rootContainer) {
intendedPlacementContainer = null;
intendedRelatedElement = null;
// Restore normal sizing
dragged.style.width = '';
dragged.style.height = '';
dragged.style.minHeight = '';
document.body.style.removeProperty('--is-dragging');
return;
}
// Get the actual parent container where SortableJS placed the item
const actualParentList = dragged.parentElement;
// Restore normal sizing - use requestAnimationFrame to prevent layout thrashing
requestAnimationFrame(() => {
dragged.style.width = '';
dragged.style.height = '';
dragged.style.minHeight = '';
document.body.style.removeProperty('--is-dragging');
});
// Check if the intended placement (where placeholder showed) differs from actual placement
// This happens when SortableJS auto-nests based on mouse position over a folder
if (intendedPlacementContainer && actualParentList !== intendedPlacementContainer) {
// The item ended up in a different container than the placeholder indicated
// Move it to where the placeholder showed it should be (next to intendedRelatedElement)
if (intendedPlacementContainer === rootContainer) {
// Placeholder was at root level - move item to root, positioned next to related element
if (intendedRelatedElement && intendedRelatedElement.parentElement === rootContainer) {
// Insert before the related element (where placeholder was)
rootContainer.insertBefore(dragged, intendedRelatedElement);
console.log("Corrected placement: moved from folder to root level where placeholder indicated");
} else if (intendedRelatedElement) {
// Related element exists but not at root - find its folder and insert before it at root
const folderItem = intendedRelatedElement.closest('li.has-children');
if (folderItem && folderItem.parentElement === rootContainer) {
rootContainer.insertBefore(dragged, folderItem);
console.log("Corrected placement: moved to root level above folder where placeholder indicated");
} else {
rootContainer.appendChild(dragged);
}
} else {
rootContainer.appendChild(dragged);
}
// Update indentation since it's now at root
applyIndentationFromDepth(dragged);
} else {
// Placeholder was in a nested list - move it there
if (intendedRelatedElement && intendedRelatedElement.parentElement === intendedPlacementContainer) {
// Insert before the related element in the nested list
intendedPlacementContainer.insertBefore(dragged, intendedRelatedElement);
} else {
intendedPlacementContainer.appendChild(dragged);
}
console.log("Corrected placement: moved to nested list where placeholder indicated");
// Update indentation for nested item
applyIndentationFromDepth(dragged);
// Ensure parent folder has proper structure
const folderItem = intendedPlacementContainer.closest('li.has-children');
if (folderItem) {
const nestedList = folderItem.querySelector('ol.nested-sortable');
if (nestedList && intendedPlacementContainer !== nestedList) {
nestedList.appendChild(dragged);
}
const parentDepth = computeDepth(folderItem);
const nestedDepth = parentDepth + 1;
if (nestedList) {
nestedList.className = 'nested-sortable nested-level-' + nestedDepth;
}
folderItem.classList.add('has-children');
}
}
} else if (actualParentList && actualParentList.classList.contains('nested-sortable') && actualParentList !== rootContainer) {
// Item is correctly in a nested list - ensure proper structure
const folderItem = actualParentList.closest('li.has-children');
if (folderItem) {
const nestedList = folderItem.querySelector('ol.nested-sortable');
if (nestedList && actualParentList !== nestedList) {
nestedList.appendChild(dragged);
}
const parentDepth = computeDepth(folderItem);
const nestedDepth = parentDepth + 1;
if (nestedList) {
nestedList.className = 'nested-sortable nested-level-' + nestedDepth;
}
folderItem.classList.add('has-children');
}
applyIndentationFromDepth(dragged);
} else {
// Item is correctly at root level
applyIndentationFromDepth(dragged);
}
// Reset tracking variables
intendedPlacementContainer = null;
intendedRelatedElement = null;
isLastItemInFolder = false;
draggedItemOriginalContainer = null;
lastContainerDecision = null;
lastContainerDecisionTime = 0;
// Apply indentation updates for ALL affected items after drag
// Use requestAnimationFrame to batch DOM updates and prevent visual jumps
requestAnimationFrame(() => {
if (dragged) {
// Update the dragged item and all its descendants
applyIndentationFromDepth(dragged);
// Update all items in the new container (siblings of dragged item)
const newContainer = dragged.parentElement;
if (newContainer && newContainer.classList.contains('nested-sortable')) {
const siblings = Array.from(newContainer.children);
siblings.forEach(sibling => {
if (sibling !== dragged && sibling.tagName === 'LI') {
applyIndentationFromDepth(sibling);
}
});
}
// Update all items in the old container (if it still exists and has items)
if (evt.from && evt.from.classList.contains('nested-sortable')) {
const oldSiblings = Array.from(evt.from.children);
oldSiblings.forEach(sibling => {
if (sibling.tagName === 'LI') {
applyIndentationFromDepth(sibling);
}
});
}
// Also update all items in the entire tree to ensure consistency
// This handles edge cases where indentation might be off
const allItems = document.querySelectorAll('#galleryTree .nested-sortable li');
allItems.forEach(li => {
const depth = computeDepth(li);
li.setAttribute('data-nesting-level', depth);
const content = li.querySelector('.gallery-item-content');
if (content) {
content.style.paddingLeft = (depth * 16) + 'px';
}
});
}
});
// Clean up previous parent if it lost its last child
if (previousParentLi) {
cleanupParentIfEmpty(previousParentLi);
}
// Remove all indicator classes
document.body.classList.remove('menu-item-dragging');
document.querySelectorAll('.potential-parent, .will-accept-child, .will-be-nested').forEach(el => {
el.classList.remove('potential-parent', 'will-accept-child', 'will-be-nested');
});
// Standard cleanup for ghost elements
document.querySelectorAll('.sortable-ghost, .sortable-chosen, .sortable-drag').forEach(item => {
item.classList.remove('sortable-ghost', 'sortable-chosen', 'sortable-drag');
});
// Check if drop was outside a valid container
if (!evt.to || !evt.to.classList.contains('nested-sortable')) {
console.warn('Item was dropped outside a valid container - reverting');
restoreFromBackup();
return;
}
// Verify no elements were lost
try {
// Check DOM elements
const endElementIds = [];
document.querySelectorAll('.nested-sortable li').forEach(li => {
if (li.dataset.id) {
endElementIds.push(li.dataset.id);
}
});
if (window._dragStartElementIds && window._dragStartElementIds.length > 0) {
const startCount = window._dragStartElementIds.length;
const endCount = endElementIds.length;
if (endCount < startCount) {
console.error(`DOM elements were lost during drag! Original: ${startCount}, New: ${endCount}`);
restoreFromBackup();
return;
}
}
// Update the data structure
updateGalleryStructure();
// Verify data model
const originalCount = countGalleries(galleriesBackup);
const newCount = countGalleries(galleries);
if (newCount < originalCount) {
console.error(`Items were lost! Original: ${originalCount}, New: ${newCount}`);
restoreFromBackup();
return;
}
// Save changes
if (window.saveGalleries) {
window.saveGalleries().then(() => {
console.log('Gallery structure saved successfully after drag');
}).catch(error => {
console.error('Error saving gallery structure after drag:', error);
});
}
} catch (error) {
console.error('Error updating gallery structure:', error);
restoreFromBackup();
}
}
});
sortableInstances.push(sortable);
} catch (error) {
console.error(`Error initializing sortable for container ${i}:`, error);
}
}
// Add event delegation for folder toggles (will be added by setupFolderToggleHandler)
setupFolderToggleHandler();
return sortableInstances;
}
// Helper function to set up folder toggle event delegation
// This must be called after galleries are rendered, in both view and edit modes
function setupFolderToggleHandler() {
const galleryTree = document.getElementById('galleryTree');
if (galleryTree && !galleryTree.dataset.folderToggleHandlerAdded) {
galleryTree.addEventListener('click', function(e) {
const target = e.target;
// Resolve the clicked row first — do not use target.closest('.menu-item.folder'),
// which matches ancestor folder labels when clicking nested pages/galleries.
const listItem = target.closest('li[data-id]');
if (!listItem) {
return;
}
const contentDiv = listItem.querySelector(':scope > .gallery-item-content');
if (!contentDiv) {
return;
}
const toggleButton = contentDiv.querySelector('.submenu-toggle');
const folderText = contentDiv.querySelector('.menu-item.folder');
const isToggleClick = toggleButton && toggleButton.contains(target);
const isFolderTextClick = folderText && folderText.contains(target);
if (isToggleClick || isFolderTextClick) {
const folderId = parseInt(listItem.dataset.id, 10);
if (folderId && !isNaN(folderId)) {
// Prevent SortableJS from intercepting this click
e.stopPropagation();
e.stopImmediatePropagation();
toggleFolder(folderId, e);
}
}
}, true); // Use capture phase to run before SortableJS
galleryTree.dataset.folderToggleHandlerAdded = 'true';
}
}
// NEW HELPER FUNCTION: Gets the nesting level of an element
function getElementNestingLevel(element) {
let level = 0;
let current = element;
// Count how many nested-sortable parents this element has
while (current && current.parentElement) {
if (current.parentElement.classList && current.parentElement.classList.contains('nested-sortable')) {
level++;
}
current = current.parentElement;
}
return level;
}
// NEW HELPER FUNCTION: Determines if an item would become nested
function wouldBeNestedItem(draggedEl, relatedEl, event) {
if (!relatedEl || !draggedEl) return false;
// Get mouse position
const mouseX = event.clientX;
// Get the bounding rect of the related element
const rect = relatedEl.getBoundingClientRect();
// Determine the nesting threshold - how far from the left edge
// the mouse needs to be to consider it a nesting operation
const nestingThreshold = 25; // pixels from left edge
// Check if mouse is within the nesting threshold from the left edge
const distanceFromLeft = mouseX - rect.left;
// Check if we should nest (mouse is within threshold of left edge)
const wouldNest = distanceFromLeft <= nestingThreshold;
// Log the calculation for debugging
console.log(`Nesting check: distance=${distanceFromLeft}px, threshold=${nestingThreshold}px, would nest=${wouldNest}`);
return wouldNest;
}
/**
* Helper function to find gallery by ID in a tree structure
* @param {Array} galleries - The gallery array to search
* @param {number} id - The ID to find
* @returns {Object|null} - The found gallery or null
*/
function findGalleryById(galleries, id) {
if (window.HelpSystem && typeof window.HelpSystem.isDemoId === 'function' && window.HelpSystem.isDemoId(id)) {
return window.HelpSystem.getDemoRecord(id);
}
if (!Array.isArray(galleries)) return null;
// First check at the current level
const directMatch = galleries.find(g => g.id == id);
if (directMatch) return directMatch;
// Then check in children
for (const gallery of galleries) {
if (gallery.children && gallery.children.length > 0) {
const childMatch = findGalleryById(gallery.children, id);
if (childMatch) return childMatch;
}
}
return null;
}
/** Detect page menu items even when isPage flag was not persisted */
function isPageMenuItem(item) {
if (!item) return false;
if (item.isPage === true) return true;
if (item.pageId && String(item.pageId).indexOf('page_') === 0) return true;
if (Array.isArray(item.pageElements) && item.pageElements.length > 0) return true;
return false;
}
/**
* Get all gallery IDs from a tree structure
* @param {Array} galleries - Array of galleries
* @returns {Array} - Flat array of all IDs
*/
function getAllGalleryIds(galleries) {
if (!Array.isArray(galleries)) return [];
let ids = [];
galleries.forEach(gallery => {
if (gallery && gallery.id) {
ids.push(gallery.id);
}
// Add child IDs if any
if (gallery.children && Array.isArray(gallery.children)) {
ids = ids.concat(getAllGalleryIds(gallery.children));
}
});
return ids;
}
/**
* Enhanced count function that handles edge cases
* @param {Array} galleryArray - The array to count
* @returns {number} - The total count of galleries
*/
function countGalleries(galleryArray) {
if (!Array.isArray(galleryArray)) return 0;
return galleryArray.reduce((count, gallery) => {
if (!gallery) return count; // Skip null/undefined items
// Count this gallery
let total = 1;
// If it has children, count them too (recursively)
if (gallery.children && Array.isArray(gallery.children)) {
total += countGalleries(gallery.children);
}
return count + total;
}, 0);
}
/**
* Enhanced backup restoration function with better debugging and notification
*/
function restoreFromBackup() {
console.warn('Restoring galleries from backup due to invalid operation');
// Verify we have a valid backup
if (!galleriesBackup || !Array.isArray(galleriesBackup) || galleriesBackup.length === 0) {
console.error('No valid backup available for restoration');
// Create a backup from the current DOM as a last resort
try {
console.log('Attempting to rebuild from DOM structure...');
rebuildGalleriesFromDOM();
return;
} catch (rebuildError) {
console.error('Failed to rebuild from DOM:', rebuildError);
// Continue with normal user notification
}
} else {
// Restore from backup
galleries = JSON.parse(JSON.stringify(galleriesBackup));
}
// Re-render the tree with the restored data
renderGalleries();
// Force reinitialize nested sortables after restoration
setTimeout(() => {
initializeNestedSortables();
}, 300);
}
/**
* Emergency recovery function that tries to rebuild galleries from DOM
* This is a last resort when backup restoration fails
*/
function rebuildGalleriesFromDOM() {
console.log('EMERGENCY RECOVERY: Attempting to rebuild galleries from DOM structure');
// Create a new galleries array
const rebuiltGalleries = [];
// This will track items we've already processed
const processedIds = new Set();
// Function to process a container and its children
function processContainer(container, parentId = null) {
// Get all immediate list items
const items = container.querySelectorAll(':scope > li');
items.forEach(item => {
// Get item ID from data attribute
const id = parseInt(item.dataset.id);
if (isNaN(id)) return; // Skip if no valid ID
// Skip already processed items to avoid duplicates
if (processedIds.has(id)) return;
processedIds.add(id);
// Try to find the original gallery data
let galleryData = null;
// First check current galleries array
const existingGallery = findGalleryById(galleries, id);
if (existingGallery) {
galleryData = { ...existingGallery };
delete galleryData.children; // We'll rebuild the hierarchy
}
// If not found, check if we have a backup
else if (galleriesBackup && Array.isArray(galleriesBackup)) {
const backupGallery = findGalleryById(galleriesBackup, id);
if (backupGallery) {
galleryData = { ...backupGallery };
delete galleryData.children; // We'll rebuild the hierarchy
}
}
// If we still don't have data, create minimal placeholder
if (!galleryData) {
// Try to extract title from DOM
const titleElement = item.querySelector('.menu-item');
const title = titleElement ? titleElement.textContent.trim() : `Item ${id}`;
galleryData = {
id: id,
title: title,
visible: !item.classList.contains('hidden-gallery')
};
}
// Update parent ID to match DOM structure
galleryData.parentId = parentId;
// Add to rebuilt galleries
rebuiltGalleries.push(galleryData);
// Process children if any
const childContainer = item.querySelector('ol.nested-sortable');
if (childContainer) {
processContainer(childContainer, id);
}
});
}
// Start with root container
const rootContainer = document.querySelector('#galleryTree > ol.nested-sortable');
if (rootContainer) {
processContainer(rootContainer);
// Replace global galleries array with our rebuilt one
galleries = rebuiltGalleries;
// Re-render with the rebuilt data
renderGalleries();
console.log(`RECOVERY COMPLETE: Rebuilt ${rebuiltGalleries.length} items from DOM`);
return true;
} else {
console.error('RECOVERY FAILED: Root container not found');
return false;
}
}
// Make sure these functions are available to the global scope
window.initializeNestedSortables = initializeNestedSortables;
window.restoreFromBackup = restoreFromBackup;
window.getElementNestingLevel = getElementNestingLevel;
window.wouldBeNestedItem = wouldBeNestedItem;
function hasDescendant(itemId, possibleDescendantId) {
// Check if possibleDescendantId is a descendant of itemId
const children = galleries.filter(g => g.parentId === itemId);
if (children.some(child => child.id === possibleDescendantId)) {
return true;
}
return children.some(child => hasDescendant(child.id, possibleDescendantId));
}
function updateGalleryStructure() {
// Create a new array to hold the updated gallery structure
const newGalleries = [];
// Function to recursively process nested lists
function processNestedList(container, parentId = null) {
if (!container || !container.children) {
console.warn('Invalid container in processNestedList', container);
return;
}
const items = container.children;
Array.from(items).forEach(item => {
if (item.tagName !== 'LI') return; // Skip non-list items
const id = parseInt(item.dataset.id);
if (isNaN(id)) {
console.warn('Invalid ID in list item:', item);
return;
}
const gallery = galleries.find(g => g.id === id);
if (gallery) {
// Create a new gallery object without children
const newGallery = { ...gallery };
if (newGallery.children) delete newGallery.children;
// Update parent ID
newGallery.parentId = parentId;
// Add to new galleries array
newGalleries.push(newGallery);
// Process any nested OL with class 'nested-sortable' within this item
const nestedList = item.querySelector('ol.nested-sortable');
if (nestedList) {
processNestedList(nestedList, id);
}
} else {
console.warn(`Gallery with ID ${id} not found in galleries array`);
}
});
}
// Start processing from the root list
const rootList = document.querySelector('#galleryTree > ol.nested-sortable');
if (rootList) {
try {
processNestedList(rootList);
// Verify that we haven't lost any galleries
if (newGalleries.length < galleries.length) {
console.error(`Gallery count mismatch! Original: ${galleries.length}, New: ${newGalleries.length}`);
// Find missing galleries
const originalIds = galleries.map(g => g.id);
const newIds = newGalleries.map(g => g.id);
const missingIds = originalIds.filter(id => !newIds.includes(id));
if (missingIds.length > 0) {
console.error('Missing gallery IDs:', missingIds);
// Add missing galleries to the new structure
missingIds.forEach(id => {
const missingGallery = galleries.find(g => g.id === id);
if (missingGallery) {
// Add it as a top-level item
const newGallery = { ...missingGallery, parentId: null };
if (newGallery.children) delete newGallery.children;
newGalleries.push(newGallery);
console.log(`Rescued gallery: ${newGallery.title} (ID: ${newGallery.id})`);
}
});
}
}
// Update the galleries array with the new structure
galleries = newGalleries;
// Save the updated structure
saveGalleries();
} catch (error) {
console.error('Error in updateGalleryStructure:', error);
throw error; // Re-throw to trigger the backup restore
}
} else {
console.warn('Root list not found');
throw new Error('Root list not found'); // Throw error to trigger backup restore
}
}
function toggleSubmenu(id, event) {
// Ensure the event doesn't interfere with drag operations
event.stopPropagation();
const listItem = getGalleryTreeListItem(id, event);
if (listItem) {
// Toggle the expanded class
listItem.classList.toggle('expanded');
// Find the submenu toggle icon and rotate it
const toggleIcon = listItem.querySelector('.toggle-icon');
if (toggleIcon) {
toggleIcon.classList.toggle('rotated');
}
}
}
/**
* Improved slugify function with robust character handling
* @param {string} text - The text to convert to a slug
* @returns {string} A URL-friendly slug
*/
function slugify(text) {
// Guard against null or empty input
if (!text || text.trim() === '') {
return 'page-' + Date.now();
}
// First convert to lowercase
let slug = text.toLowerCase();
// Replace spaces with hyphens
slug = slug.split(' ').join('-');
// Filter out unwanted characters (keep only a-z, 0-9, and hyphens)
let filtered = '';
for (let i = 0; i < slug.length; i++) {
const char = slug.charAt(i);
if ((char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char === '-') {
filtered += char;
}
}
slug = filtered;
/*
// Clean up multiple hyphens
while (slug.indexOf('--') !== -1) {
slug = slug.replace('--', '-');
}
*/
// Remove leading and trailing hyphens
if (slug.startsWith('-')) {
slug = slug.substring(1);
}
if (slug.endsWith('-')) {
slug = slug.substring(0, slug.length - 1);
}
// If slug is empty after processing, use a default
if (!slug || slug === '-') {
return 'page-' + Date.now();
}
return slug;
}
// Update the addPage function to use our new robust slugify function
/**
* Adds a new page to the galleries array
* @returns {number|null} The new page ID or null if error
*/
function addPage() {
try {
// Get the page title
const titleInput = document.getElementById('galleryTitle');
if (!titleInput) {
console.error('Page title input not found');
return;
}
const title = titleInput.value.trim();
// Validate title is not empty
if (!title) {
alert('Please enter a page title');
return;
}
// Get parent ID with error handling
const parentSelect = document.getElementById('galleryParent');
const parentId = parentSelect && parentSelect.value ? parseInt(parentSelect.value) : null;
// Generate a unique ID for the page
const id = Date.now();
const pageId = `page_${id}`;
// Calculate slug using our more robust slugify function
const pageSlug = slugify(title);
// Create the page object with explicit properties
const page = {
id: id,
title: title,
isPage: true,
isIntegrated: true,
isSubmenu: false,
pageId: pageId,
visible: true,
parentId: parentId,
siteId: siteId,
// Set slug and URL explicitly
slug: pageSlug,
url: '/' + pageSlug
};
console.log(`Creating new page with ID: ${id}, title: "${title}"`);
// Add to galleries array
galleries.push(page);
// CRITICAL: Ensure window.galleries is synchronized
if (typeof window.galleries === 'undefined') {
window.galleries = galleries;
} else {
// Make sure the page is in window.galleries too
const existingIndex = window.galleries.findIndex(g => g.id === id);
if (existingIndex >= 0) {
window.galleries[existingIndex] = page;
} else {
window.galleries.push(page);
}
}
// Initialize empty page elements array in PageManager
if (window.PageManager) {
window.PageManager.elements[pageId] = [];
}
// Update UI first
renderGalleries();
clearAddForm();
toggleAddForm();
// Set as active page
console.log(`Setting new page "${page.title}" as active`);
activeGalleryId = id;
window.activeGalleryId = id;
// Update active states in the menu
updateActiveStates();
// Save to server
saveGalleries().then(() => {
console.log(`Page "${page.title}" saved successfully`);
// Double check that our gallery is still in the array
const pageExists = galleries.some(g => g.id === id);
console.log(`Page exists in galleries array: ${pageExists}`);
if (pageExists) {
// Load the new page after a slight delay to ensure DOM is updated
setTimeout(() => {
console.log(`Loading new page "${page.title}" with ID: ${id}`);
loadPage(id);
}, 50);
} else {
console.error(`Page with ID ${id} not found in galleries array after save`);
}
}).catch(error => {
alert('Error saving page. Please try again.');
});
return id;
} catch (error) {
console.error('Error adding page:', error);
alert('Error adding page. Please try again.');
return null;
}
}
/**
* Ensures a slug is unique among existing galleries
* @param {string} slug - The base slug to check
* @param {Array} existingGalleries - Array of existing gallery configs
* @returns {string} A unique slug
*/
function ensureUniqueSlug(slug, existingGalleries) {
// Guard against invalid slugs
if (!slug || slug === '-') {
slug = 'page-' + Date.now();
}
let finalSlug = slug;
let counter = 1;
// Check if slug already exists
while (existingGalleries.some(g => g.slug === finalSlug)) {
finalSlug = `${slug}-${counter}`;
counter++;
}
return finalSlug;
}
/**
* Adds a new gallery based on the selected type
* @returns {number|null} The new gallery ID or null if error
*/
function addGallery() {
try {
// Get the gallery title
const titleInput = document.getElementById('galleryTitle');
if (!titleInput) {
console.error('Gallery title input not found');
return;
}
const title = titleInput.value.trim();
// Get the selected gallery type
const galleryTypeRadio = document.querySelector('input[name="galleryType"]:checked');
if (!galleryTypeRadio) {
console.error('No gallery type selected');
alert('Please select a gallery type');
return;
}
const galleryType = galleryTypeRadio.value;
console.log("Selected gallery type:", galleryType);
// If this is a page, use the addPage function
if (galleryType === 'page') {
console.log("Creating page instead of gallery");
return addPage();
}
// For spacer type, we don't require a title
if (galleryType !== 'spacer' && !title) {
alert('Please enter a gallery title');
return;
}
// Continue with gallery creation logic
// Generate unique ID
const id = Date.now();
// Get parent gallery if selected
const parentSelect = document.getElementById('galleryParent');
const parentId = parentSelect.value ? parseInt(parentSelect.value) : null;
// Get URL if this is an external gallery
const galleryUrl = document.getElementById('galleryUrl');
const url = galleryTypeRadio.value === 'external' && galleryUrl
? galleryUrl.value.trim()
: "";
// Set basic properties
const gallery = {
id: id,
title: title || "Spacer", // Use "Spacer" as internal title if no title provided
parentId: parentId,
visible: true
};
// Set type-specific properties
if (galleryTypeRadio.value === 'spacer') {
// Spacer properties
gallery.isSpacer = true;
gallery.title = title || "Spacer"; // For internal reference
} else if (galleryTypeRadio.value === 'integrated') {
// Generate pageId for integrated galleries
gallery.isIntegrated = true;
gallery.pageId = generatePageId();
// Generate and ensure unique slug
const baseSlug = slugify(title);
gallery.slug = ensureUniqueSlug(baseSlug, galleries);
gallery.url = "/" + gallery.slug;
// Get the siteId from multiple possible sources
const siteId = window.Parameters?.siteId ||
window.siteId ||
document.querySelector('meta[name="hydra-site-id"]')?.getAttribute('content') ||
'';
// Add galleryOptions with GUID=siteId as the manualCollectionName
gallery.galleryOptions = {
manualCollectionName: `GUID=${siteId}`,
isIntegratedGallery: true,
pageId: gallery.pageId, // Store pageId for reference
siteId: siteId
};
} else if (galleryTypeRadio.value === 'folder') {
gallery.isFolder = true;
gallery.isCollapsed = true; // Make folders closed by default
} else if (galleryTypeRadio.value === 'external') {
// External URL
gallery.url = url;
gallery.isExternal = true; // Add this flag to identify external URLs
}
console.log(`Creating new gallery with ID: ${id}, title: "${title}"`);
// Add to galleries array
galleries.push(gallery);
// CRITICAL: Ensure window.galleries is synchronized
if (typeof window.galleries === 'undefined') {
window.galleries = galleries;
} else {
// Make sure the gallery is in window.galleries too
const existingIndex = window.galleries.findIndex(g => g.id === id);
if (existingIndex >= 0) {
window.galleries[existingIndex] = gallery;
} else {
window.galleries.push(gallery);
}
}
// Update UI first
renderGalleries();
clearAddForm();
toggleAddForm();
// Save galleries after UI update
saveGalleries().then(() => {
console.log(`Gallery "${gallery.title}" saved successfully`);
// Double check that our gallery is still in the array
const galleryExists = galleries.some(g => g.id === id);
console.log(`Gallery exists in galleries array: ${galleryExists}`);
// For non-folder types, load the new gallery content after a slight delay
if (!gallery.isFolder && !gallery.isSubmenu && !gallery.isSpacer && gallery.visible !== false) {
setTimeout(() => {
console.log(`Loading new gallery "${gallery.title}" with ID: ${id}`);
if (gallery.isIntegrated) {
loadGallery(id);
} else if (gallery.isExternal && gallery.url) {
// For external URLs, set the iframe src and mark active after navigation target is ready
activeGalleryId = id;
window.activeGalleryId = id;
updateActiveStates();
const frame = document.getElementById('galleryFrame');
if (frame) frame.src = gallery.url;
}
}, 50);
}
}).catch(error => {
console.error(`Error saving gallery "${gallery.title}":`, error);
alert('Error saving gallery. Please try again.');
});
// Return the new gallery ID
return id;
} catch (error) {
console.error('Error adding gallery:', error);
alert('Error adding gallery. Please try again.');
}
}
function debugDOMState(message) {
console.log(`[DEBUG] ${message}`);
const galleryContainer = document.querySelector('.gallery-container');
const iframe = document.getElementById('galleryFrame');
const pageContainers = document.querySelectorAll('.page-container');
console.log(`- Gallery container: ${galleryContainer ? 'found' : 'not found'}`);
console.log(`- iframe: ${iframe ? 'found' : 'not found'}, display: ${iframe?.style.display}`);
console.log(`- Page containers: ${pageContainers.length} found`);
pageContainers.forEach((container, index) => {
console.log(` - Container ${index}: display: ${container.style.display}, visibility: ${container.style.visibility}`);
});
}
/**
* Global helper: whether edit tools are explicitly toggled open.
* Do not use Parameters.isInEditor — that is also true for logged-in admins (cache bypass).
* Do not infer from sidebar/page .editing alone (can desync from the toggle).
*/
window.isInEditMode = function() {
if (document.body.classList.contains('edit-mode-active')) {
return true;
}
if (typeof window.isEditing === 'boolean') {
return window.isEditing;
}
return false;
};
/**
* This function synchronizes edit mode state across all components
*/
window.updateGlobalEditState = function(isEditing) {
console.log('Updating global edit state:', isEditing);
// Update global variable
window.isEditing = isEditing;
// Update Parameters object if it exists
if (window.Parameters) {
window.Parameters.isInEditor = isEditing;
}
// Update sidebar class
const sidebar = document.querySelector('.sidebar');
if (sidebar) {
if (isEditing) {
sidebar.classList.add('editing');
} else {
sidebar.classList.remove('editing');
}
}
// Update page containers
const pageContainers = document.querySelectorAll('.page-container');
pageContainers.forEach(container => {
if (isEditing) {
container.classList.add('editing');
} else {
container.classList.remove('editing');
}
});
// Notify all components via event
document.dispatchEvent(new CustomEvent('edit-mode-changed', {
detail: { editing: isEditing }
}));
if (document.body.classList.contains('menu-layout-horizontal') && typeof window.syncContentMarginToHorizontalHeader === 'function') {
window.syncContentMarginToHorizontalHeader();
}
if (typeof window.syncGalleryOptionsButtonVisibility === 'function') {
window.syncGalleryOptionsButtonVisibility();
}
// Gallery Options panel styling depends on body.edit-mode-active — hide it when leaving edit mode
if (!isEditing && typeof window.closeOptionsPanel === 'function') {
window.closeOptionsPanel();
}
};
/** Show or hide gallery options (#editorButton) based on edit mode and active content type */
window.syncGalleryOptionsButtonVisibility = function() {
const btn = document.getElementById('editorButton');
if (!btn) return;
const inEditMode = document.body.classList.contains('edit-mode-active');
// When content type is explicitly gallery, ignore leftover .page-container nodes
// (common when Help sandbox switches from a page to Example Gallery).
const onPageView = window._activeContentType === 'gallery'
? false
: (window._activeContentType === 'page' ||
!!document.querySelector('.gallery-container .page-container'));
let isActiveGallery = false;
const activeId = window.activeGalleryId;
const list = window.galleries || (typeof galleries !== 'undefined' ? galleries : null);
const activeItem = activeId != null && list && typeof findGalleryById === 'function'
? findGalleryById(list, activeId)
: (Array.isArray(list) ? list.find(function(g) { return g && g.id === activeId; }) : null);
if (activeId != null && inEditMode && !onPageView) {
isActiveGallery = !!(activeItem && !isPageMenuItem(activeItem) && !activeItem.isFolder && !activeItem.isSpacer && !activeItem.isExternal);
}
const shouldShow = inEditMode && !onPageView && isActiveGallery;
if (shouldShow) {
if (window.Parameters) {
window.Parameters.isInEditor = true;
}
btn.classList.remove('gallery-options-force-hidden');
btn.style.removeProperty('display');
btn.style.setProperty('display', 'flex', 'important');
btn.style.pointerEvents = 'auto';
} else {
btn.classList.add('gallery-options-force-hidden');
btn.style.setProperty('display', 'none', 'important');
btn.style.pointerEvents = '';
}
};
document.addEventListener('gallery-loaded', function() {
if (typeof window.syncGalleryOptionsButtonVisibility === 'function') {
window.syncGalleryOptionsButtonVisibility();
}
});
document.addEventListener('page-loaded', function() {
if (typeof window.syncGalleryOptionsButtonVisibility === 'function') {
window.syncGalleryOptionsButtonVisibility();
}
});
/**
* Wait for PageManager after CDN→proxy late script load (ISP/Firefox clients).
* Home/page bootstrap can run before the proxy copy of page-manager-v260720-021.js executes.
*/
function whenPageManagerReady(callback, options) {
const maxAttempts = (options && options.maxAttempts) || 50;
const intervalMs = (options && options.intervalMs) || 100;
let attempts = 0;
const run = function() {
if (window.PageManager && typeof window.PageManager.loadPage === 'function') {
callback(window.PageManager);
return;
}
attempts += 1;
if (attempts >= maxAttempts) {
console.error('❌ [loadPage] Timed out waiting for PageManager after', attempts, 'attempts');
if (options && typeof options.onTimeout === 'function') options.onTimeout();
return;
}
if (attempts === 1 || attempts % 10 === 0) {
console.warn('⏳ [loadPage] Waiting for PageManager (CDN proxy late load?) attempt', attempts + '/' + maxAttempts);
}
setTimeout(run, intervalMs);
};
run();
}
/**
* loadPage function that safely handles undefined galleries
* @param {number} id - The gallery ID
* @param {Event} event - Optional event object
*/
function loadPage(id, event){
if (event) {
event.preventDefault();
event.stopPropagation();
const now = Date.now();
const lastCallTime = window._lastPageLoadTime || 0;
window._lastPageLoadTime = now;
if (now - lastCallTime < 100) {
console.log('Ignoring duplicate loadPage call');
return;
}
}
console.log('loadPage: Loading page with gallery ID:', id);
stopAutoAdvanceTimer();
let galleriesData = window.galleries || galleries;
const gallery = findGalleryById(galleriesData, id);
if (!gallery) {
console.warn('No gallery found with ID:', id, 'for page load.');
return;
}
if (typeof window.removeGalleryScriptsWithPause === 'function') {
window.removeGalleryScriptsWithPause();
}
window.activeGalleryId = id;
activeGalleryId = id;
window._activeContentType = 'page';
const galleryContainer = document.querySelector('.gallery-container');
if (galleryContainer) {
galleryContainer.innerHTML = '';
}
if (!gallery.pageId) gallery.pageId = `page_${id}`;
if (!gallery.isPage) gallery.isPage = true;
if (typeof window.syncGalleryOptionsButtonVisibility === 'function') {
window.syncGalleryOptionsButtonVisibility();
}
if (!window._pageIdToGalleryId) window._pageIdToGalleryId = {};
window._pageIdToGalleryId[gallery.pageId] = id;
if (gallery.pageElements && Array.isArray(gallery.pageElements)) {
if (window.PageManager && window.PageManager.elements) {
window.PageManager.elements[gallery.pageId] = [...gallery.pageElements];
}
} else if (window.PageManager && window.PageManager.elements &&
window.PageManager.elements[gallery.pageId] &&
window.PageManager.elements[gallery.pageId].length > 0) {
gallery.pageElements = [...window.PageManager.elements[gallery.pageId]];
}
if (window.PageManager && typeof window.PageManager.loadPage === 'function') {
try {
window.PageManager.loadPage(gallery.pageId);
const isCurrentlyEditing = typeof isInEditMode === 'function' ? isInEditMode() : false;
if (isCurrentlyEditing && typeof window.PageManager.setEditMode === 'function') {
window.PageManager.setEditMode(isCurrentlyEditing);
}
} catch (error) {
console.error('Error loading page with PageManager:', error);
}
} else {
console.error('PageManager not found yet — waiting for CDN proxy script');
whenPageManagerReady(function() {
try {
if (gallery.pageElements && Array.isArray(gallery.pageElements) && window.PageManager.elements) {
window.PageManager.elements[gallery.pageId] = [...gallery.pageElements];
}
window.PageManager.loadPage(gallery.pageId);
console.log('loadPage: PageManager became available — page render retried');
} catch (error) {
console.error('Error loading page with PageManager after wait:', error);
}
});
}
// Apply menu visibility
const bodyEl = document.body;
if (shouldApplyMenuHiddenForEmbedOrGallery(gallery)) {
bodyEl.classList.add('menu-hidden-on-page');
} else {
bodyEl.classList.remove('menu-hidden-on-page'); // Ensure menu is visible if in edit mode or not hidden
}
if (typeof updateActiveStates === 'function') updateActiveStates();
if (typeof updateMobileTitle === 'function') updateMobileTitle();
if (typeof closeMobileMenu === 'function') closeMobileMenu();
// Close the gallery options panel when navigating to a different page
if (typeof window.closeOptionsPanel === 'function') {
window.closeOptionsPanel();
}
if (typeof updateURLWithGallerySlug === 'function') updateURLWithGallerySlug(gallery);
if (typeof window.syncGalleryOptionsButtonVisibility === 'function') {
window.syncGalleryOptionsButtonVisibility();
}
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
window.dispatchEvent(new CustomEvent('page-loaded', {
detail: { pageId: gallery.pageId, galleryId: id }
}));
}
}
function generatePageId() {
const characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
const charactersLength = characters.length;
for (let i = 0; i < 8; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return result;
};
function normalizeNameForComparison(name) {
if (!name) {
console.log("Empty name passed to normalization");
return '';
}
console.log(`Normalizing name: "${name}"`);
// Step 1: Convert to lowercase
let normalized = name.toLowerCase();
console.log(`After lowercase: "${normalized}"`);
// Step 2: Replace spaces with hyphens (without regex)
normalized = normalized.split(' ').join('-');
console.log(`After space replacement: "${normalized}"`);
// Step 3: Filter out unwanted characters (without regex)
let filtered = '';
for (let i = 0; i < normalized.length; i++) {
const char = normalized.charAt(i);
// Keep only a-z, 0-9, and hyphens
if ((char >= 'a' && char <= 'z') ||
(char >= '0' && char <= '9') ||
char === '-') {
filtered += char;
}
}
normalized = filtered;
console.log(`After character filtering: "${normalized}"`);
// Step 4: Clean up multiple hyphens (without regex)
while (normalized.indexOf('--') !== -1) {
normalized = normalized.split('--').join('-');
}
// Step 5: Remove leading and trailing hyphens (without regex)
if (normalized.charAt(0) === '-') {
normalized = normalized.substring(1);
}
if (normalized.charAt(normalized.length - 1) === '-') {
normalized = normalized.substring(0, normalized.length - 1);
}
// Check if the result is valid
if (!normalized || normalized === '-') {
console.log(`WARNING: Normalization produced invalid result: "${normalized}"`);
} else {
console.log(`Final normalized slug: "${normalized}"`);
}
return normalized;
}
function ensureUniqueSlug(slug, existingGalleries) {
if (!slug) return 'gallery';
let finalSlug = slug;
let counter = 1;
// Check if slug exists in any gallery
const slugExists = function(s) {
for (let i = 0; i < existingGalleries.length; i++) {
if (existingGalleries[i].slug === s) {
return true;
}
}
return false;
};
// Keep incrementing counter until unique
while (slugExists(finalSlug)) {
finalSlug = slug + '-' + counter;
counter++;
}
return finalSlug;
}
/**
* Gathers the current complete site configuration from the client-side state
* and triggers a download of the data as a single JSON file.
* This provides a simple way to back up or export the current site structure.
*/
function downloadCurrentSiteJSON() {
try {
console.log('Gathering current site data for download...');
// Get the current hostname to use in the filename.
const hostname = window.location.hostname.replace('www.', '');
// --- FIX: Ensure we get the most up-to-date menu styles ---
// The MenuStyleCustomizer.settings object is the most reliable source
// for the current styles being used on the client.
const currentMenuStyles = window.MenuStyleCustomizer?.settings || {};
// Get current galleries
const currentGalleries = window.galleries || galleries || [];
// --- STREAMLINED: Process galleries without including image data ---
const enhancedGalleries = currentGalleries.map(gallery => {
// If this is a Classic collection gallery, add note about manual retrieval
if (gallery.galleryOptions?.manualCollectionName?.startsWith('GUID=')) {
const classicGuid = gallery.galleryOptions.manualCollectionName.substring(5);
console.log(`Processing Classic collection gallery: ${gallery.title} (GUID: ${classicGuid})`);
return {
...gallery,
_note: `This gallery references Classic collection GUID=${classicGuid}. To get the full image data with captions, you may need to manually fetch the Classic collection JSON from: https://storage.neonsky.app/sites:site_${classicGuid}.json`,
_classicGuid: classicGuid,
_requiresClassicData: true
};
}
return gallery;
});
// Assemble the complete site configuration object from global state variables.
// This structure mirrors what the `save-config` API endpoint expects, ensuring
// the exported file is a complete and re-importable replica.
const siteData = {
// The core menu/page structure with enhanced Classic collection data.
galleries: enhancedGalleries,
// Admin emails are managed server-side for security. We export the list if it's
// available on the client from a previous check, but the server remains the
// source of truth. A re-import will not overwrite the admin list.
adminEmails: window.siteConfig?.adminEmails || [],
// The unique ID for the site.
siteId: window.siteId || window.Parameters?.siteId || '',
// Custom styling rules for the menu.
menuStyles: currentMenuStyles,
// All elements configured to appear in the sidebar.
sidebarElements: window.SidebarManager?.elements || [],
// Global site metadata for SEO and analytics.
siteMetadata: window.siteMetadata || {},
// Add metadata about Classic collections that need manual data retrieval
_classicCollections: enhancedGalleries
.filter(g => g._requiresClassicData)
.map(g => ({
title: g.title,
guid: g._classicGuid,
note: g._note
}))
};
console.log('Enhanced site data collected for export:', siteData);
// Convert the JavaScript object to a formatted JSON string.
const jsonString = JSON.stringify(siteData, null, 2);
// Create a Blob, which is a file-like object of immutable, raw data.
const blob = new Blob([jsonString], { type: 'application/json' });
// Create a temporary link element to trigger the browser's download functionality.
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${hostname}-config.json`; // e.g., 'yoursite.com-config.json'
document.body.appendChild(a);
a.click();
// Clean up by removing the temporary link and revoking the object URL to free up memory.
document.body.removeChild(a);
URL.revokeObjectURL(url);
console.log('Enhanced JSON download initiated successfully.');
// Show a helpful message about Classic collections
const classicCount = siteData._classicCollections?.length || 0;
if (classicCount > 0) {
console.log(`Export Summary: ${classicCount} Classic collection(s) detected. The exported JSON includes gallery structure and settings, but for full image data with captions, you may need to manually fetch the Classic collection JSON files.`);
}
} catch (error) {
console.error('Error creating JSON download:', error);
alert('An error occurred while preparing the download. Please check the console.');
}
}
// Build Hydra Classic-style NDJSON (first line: metadata; second line: imageMetadata array)
function buildHydraClassicNdjson(classicGuid, images) {
const header = {
title: 'Untitled',
description: '',
version: '1',
isClassic: true,
isClassicCollection: true,
classicGuid: 'GUID=' + String(classicGuid || '')
};
const imageArray = Array.isArray(images) ? images.map(function(img, idx) {
var path = '';
if (img) {
path = img.image || img.link || img.originalUrl || (img.urls && img.urls.original) || '';
}
// Derive filename base from any provided path/filename and normalize extension to .jpg
var fnameBase = '';
if (typeof path === 'string' && path.length > 0) {
var slash = path.lastIndexOf('/');
var raw = slash >= 0 ? path.substring(slash + 1) : path;
// remove query/hash
var q = raw.indexOf('?'); if (q >= 0) raw = raw.substring(0, q);
var h = raw.indexOf('#'); if (h >= 0) raw = raw.substring(0, h);
// strip extension if present
var dot = raw.lastIndexOf('.');
fnameBase = dot > 0 ? raw.substring(0, dot) : raw;
} else if (img && typeof img.filename === 'string') {
var rf = img.filename;
var q2 = rf.indexOf('?'); if (q2 >= 0) rf = rf.substring(0, q2);
var h2 = rf.indexOf('#'); if (h2 >= 0) rf = rf.substring(0, h2);
var d2 = rf.lastIndexOf('.');
fnameBase = d2 > 0 ? rf.substring(0, d2) : rf;
}
var ext = (img && img.metadata && typeof img.metadata.extension === 'string' && img.metadata.extension.startsWith('.'))
? img.metadata.extension : '.jpg';
var filename = fnameBase ? (fnameBase + ext) : '';
// Build CDN image URL using site GUID: https://cdn.neonsky.app/{siteGuid}/images/{filename}
// Note: classicGuid is a site GUID, not an image GUID - it identifies the site, and images are stored under that site's path
var guidRaw = String(classicGuid || '').replace(/^GUID=/, '');
var cdnImageUrl = filename && guidRaw ? ('https://cdn.neonsky.app/' + guidRaw + '/images/' + filename) : path;
var rec = {
caption: (img && (img.caption || img.description)) || '',
description: (img && (img.description || img.caption)) || '',
isPreview: idx === 1,
title: (img && img.title) || '',
'alt-text': (img && (img['alt-text'] || img.altText || '')) || '',
image: cdnImageUrl || '',
link: '',
'link-text': '',
filename: filename,
isClassic: true,
isClassicCollection: true,
classicGuid: 'GUID=' + String(classicGuid || '')
};
return rec;
}) : [];
// Build NDJSON as: header line, imageMetadata array line, then one line per full image record
var body = { imageMetadata: imageArray };
var out = [JSON.stringify(header), JSON.stringify(body)];
for (var k = 0; k < imageArray.length; k++) {
out.push(JSON.stringify(imageArray[k]));
}
return out.join('\n') + '\n';
}
async function uploadNdjsonToTigris(siteId, pageId, ndjsonContent) {
// Dual upload strategy: Fly.io (uncompressed) + Irys (compressed if <800KB)
const fileName = String(siteId || '') + '_' + String(pageId || '') + '.ndjson';
const flyUrl = 'https://hydra-press-v2.fly.dev/publish';
const irysUrl = 'https://hydra-press-v2.fly.dev/upload-irys';
console.error('Starting dual NDJSON upload for:', fileName);
console.error('Content length:', ndjsonContent.length, 'characters');
// COMPRESSION: Compress for Irys only (not Tigris)
console.error('🔄 Compressing NDJSON with gzip for Irys...');
const encoder = new TextEncoder();
const ndjsonBytes = encoder.encode(ndjsonContent);
const compressedStream = new Response(ndjsonBytes).body.pipeThrough(new CompressionStream('gzip'));
const compressedBlob = await new Response(compressedStream).blob();
const compressedBytes = new Uint8Array(await compressedBlob.arrayBuffer());
const originalSize = ndjsonBytes.length;
const compressedSize = compressedBytes.length;
const compressionRatio = ((1 - compressedSize / originalSize) * 100).toFixed(2);
console.error('📊 Compression results:', {
original: originalSize + ' bytes (' + (originalSize / 1024).toFixed(2) + ' KB)',
compressed: compressedSize + ' bytes (' + (compressedSize / 1024).toFixed(2) + ' KB)',
ratio: compressionRatio + '%'
});
const IRYS_SIZE_LIMIT = 800 * 1024; // 800KB limit for compressed Irys upload
const shouldUploadToIrys = compressedSize < IRYS_SIZE_LIMIT;
console.error('Irys upload eligible: ' + shouldUploadToIrys + ' (compressed: ' + (compressedSize / 1024).toFixed(2) + ' KB)');
// STEP 1: Upload to Irys (non-blocking, compressed if <800KB)
let irysResult = null;
if (shouldUploadToIrys) {
try {
console.error('🔄 Starting Irys upload (compressed, non-blocking)...');
// Convert compressed bytes to base64
const compressedBase64 = btoa(String.fromCharCode(...compressedBytes));
const irysResp = await fetch(irysUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
compressedData: compressedBase64,
originalSize: originalSize,
compressedSize: compressedSize,
siteId: siteId,
pageId: pageId
})
});
if (irysResp.ok) {
irysResult = await irysResp.json();
console.error('✅ Irys upload successful:', irysResult.txId);
console.error(' Irys Gateway:', irysResult.urls?.irys);
console.error(' ar.io Gateway:', irysResult.urls?.arweave);
// Log wallet balance and cost
if (irysResult.walletBalance !== undefined) {
console.error('💰 Wallet Balance:', irysResult.walletBalance.toFixed(6), 'AR');
}
if (irysResult.uploadCost !== undefined) {
const costUSD = (irysResult.uploadCost * 20).toFixed(4);
if (irysResult.uploadCost === 0) {
console.error('💸 Upload Cost: FREE (0 AR)');
} else {
console.error('💸 Upload Cost:', irysResult.uploadCost.toFixed(6), 'AR (~$' + costUSD + ' USD at $20/AR)');
}
}
} else {
const errorText = await irysResp.text().catch(() => 'Unknown error');
console.warn('⚠️ Irys upload failed (non-critical):', irysResp.status, errorText);
}
} catch (irysError) {
console.warn('⚠️ Irys upload error (non-critical):', irysError.message);
// Continue - Irys failure doesn't block Fly.io upload
}
} else {
console.error('⏭️ Skipping Irys upload (compressed size exceeds 800KB limit)');
}
// STEP 2: Upload to Fly.io (CRITICAL - must succeed, uncompressed)
// Hydra sites require Bearer auth - get token from TokenManager/localStorage (same as Edition Publisher)
try {
console.error('🔄 Starting Fly.io upload (uncompressed)...');
const flyHeaders = { 'content-type': 'application/json' };
let authToken = null;
try {
if (window.TokenManager) {
if (typeof window.TokenManager.getHydraAuthToken === 'function') {
try { authToken = window.TokenManager.getHydraAuthToken(); } catch (e) {}
}
if (!authToken && typeof window.TokenManager.getBestToken === 'function') {
try {
const tw = window.TokenManager.getBestToken();
authToken = tw && tw.startsWith('Bearer ') ? tw.substring(7) : tw;
} catch (e) {}
}
}
if (!authToken) authToken = localStorage.getItem('hydra_auth_token');
if (!authToken) authToken = window.authToken;
if (authToken) {
flyHeaders['Authorization'] = authToken.includes('.') || authToken.startsWith('hydra:')
? 'Bearer ' + authToken
: (authToken.startsWith('Bearer ') ? authToken : 'Bearer hydra:' + authToken);
console.error('🔐 Fly.io auth: token attached');
} else {
console.warn('⚠️ No auth token - Fly.io may reject Hydra site upload (401)');
}
} catch (authErr) {
console.warn('⚠️ Auth token lookup failed:', authErr.message);
}
const flyResp = await fetch(flyUrl, {
method: 'POST',
headers: flyHeaders,
body: JSON.stringify({ ndjsonContent, dataFileName: fileName })
});
console.log('Server response status:', flyResp.status);
if (!flyResp.ok) {
const errorText = await flyResp.text().catch(() => 'Unknown error');
console.error('❌ Fly.io upload failed', flyResp.status, 'for', fileName + ':', errorText);
if (flyResp.status === 401) {
console.error('💡 Tip: Open the Edition Publisher (or log in as admin) first to authenticate for Fly.io uploads.');
}
return { success: false, irysResult: null };
}
const flyData = await flyResp.json().catch(() => null);
if (flyData && flyData.urls) {
console.log('✅ Fly.io upload successful:', flyData.urls);
console.log(' Tigris URL:', flyData.urls.urlTigris);
// Return comprehensive result
const result = {
success: true,
flyUrls: flyData.urls,
irysResult: irysResult,
uploadedTo: irysResult ? ['fly', 'irys'] : ['fly'],
fileSize: compressedSize, // Irys compressed size
originalFileSize: originalSize // Original uncompressed size
};
console.log('📦 Upload complete:', {
destinations: result.uploadedTo.join(' + '),
txId: irysResult?.txId || 'N/A',
flyUrl: flyData.urls.urlTigris
});
return result;
} else {
console.warn('Fly.io upload response missing URLs:', flyData);
return { success: false, irysResult: null };
}
} catch (flyError) {
console.error('❌ Fly.io upload error for', fileName + ':', flyError);
return { success: false, irysResult: null };
}
}
async function importClassicGalleries() {
// Browser-scoped helper: mirrors server-side behavior with escaped patterns for template-safe JS
function cleanDescriptionLeadingLines(text) {
if (!text || typeof text !== 'string') return text;
let result = text;
// Remove leading empty TEXTFORMAT blocks: <TEXTFORMAT><P><FONT></FONT></P></TEXTFORMAT>
result = result.replace(
new RegExp(
'^(\s*\<TEXTFORMAT[^\>]*\>\s*\<P[^\>]*\>\s*\<FONT[^\>]*\>\s*\<\/FONT\>\s*\<\/P\>\s*\<\/TEXTFORMAT\>\s*)+',
'i'
),
''
);
// Remove leading empty P tags
result = result.replace(
new RegExp('^(\s*\<P[^\>]*\>\s*\<\/P\>\s*)+', 'i'),
''
);
// Remove leading empty FONT tags
result = result.replace(
new RegExp('^(\s*\<FONT[^\>]*\>\s*\<\/FONT\>\s*)+', 'i'),
''
);
// Remove leading BR tags
result = result.replace(
new RegExp('^(\s*\<br\s*\/\?>\s*)+', 'i'),
''
);
// Remove any remaining leading whitespace
return result.replace(new RegExp('^\s+', 'i'), '').trim();
}
// Browser-scoped helper: fix multi-line links (template-safe escaped regex)
function fixMultilineLinks(text) {
if (!text || typeof text !== 'string') return text;
return text.replace(
new RegExp('<a[^>]*>.*?Click.*?<\/a><a[^>]*>.*?HERE.*?<\/a><a[^>]*>.*?to Add to Cart.*?<\/a>', 'gis'),
function (match) {
const first = match.match(new RegExp('<a href="([^"]*)" target="([^"]*)"', 'i'));
if (first) {
return '<a href="' + first[1] + '" target="' + first[2] + '">Click HERE to Add to Cart</a>';
}
return match;
}
);
}
// Browser-scoped helper: convert plain text link phrase to HTML link
function convertPlainTextLinks(text) {
if (!text || typeof text !== 'string') return text;
return text.replace(
new RegExp('Click HERE to Add to Cart', 'g'),
'<a href="#" target="_blank">Click HERE to Add to Cart</a>'
);
}
// Get input elements, including the new checkbox
const guidInputEl = document.getElementById('classicGuid');
const pastedJsonEl = document.getElementById('pastedJson');
const parentIdInputEl = document.getElementById('importParent');
const createSubmenuCheckboxEl = document.getElementById('createSubmenu');
const submenuTitleInputEl = document.getElementById('submenuTitle');
const replaceMenuCheckboxEl = document.getElementById('replaceMenuCheckbox');
// Get values from inputs
const guidInput = guidInputEl instanceof HTMLInputElement ? guidInputEl.value.trim() : '';
const pastedJson = pastedJsonEl instanceof HTMLTextAreaElement ? pastedJsonEl.value.trim() : '';
const replaceMenu = replaceMenuCheckboxEl instanceof HTMLInputElement ? replaceMenuCheckboxEl.checked : false;
let parentId = parentIdInputEl instanceof HTMLSelectElement && parentIdInputEl.value ? parseInt(parentIdInputEl.value) : null;
let createSubmenu = createSubmenuCheckboxEl instanceof HTMLInputElement ? createSubmenuCheckboxEl.checked : false;
let submenuTitle = submenuTitleInputEl instanceof HTMLInputElement ? submenuTitleInputEl.value.trim() : '';
// If replacing the menu, ignore parent/submenu settings
if (replaceMenu) {
parentId = null;
createSubmenu = false;
}
// Validate submenu title if checkbox is checked
if (createSubmenu && !submenuTitle) {
alert('Please provide a title for the submenu');
return;
}
// Show status display
const statusEl = document.getElementById('importStatus');
const progressBarEl = statusEl ? statusEl.querySelector('.progress-bar') : null;
const statusMessageEl = statusEl ? statusEl.querySelector('.status-message') : null;
const progressBar = progressBarEl instanceof HTMLElement ? progressBarEl : null;
const statusMessage = statusMessageEl instanceof HTMLElement ? statusMessageEl : null;
if (statusEl && progressBar && statusMessage) {
statusEl.style.display = 'block';
statusEl.classList.add('importing');
progressBar.style.width = '10%';
statusMessage.textContent = 'Preparing to import...';
}
try {
let classicData;
let classicGuid; // Will hold the primary GUID for image path lookups
// --- LOGIC TO GET DATA: Prioritize Pasted JSON ---
if (pastedJson) {
if (statusMessage) statusMessage.textContent = 'Parsing pasted JSON...';
classicData = JSON.parse(pastedJson);
if (!classicData.galleries || !Array.isArray(classicData.galleries)) {
throw new Error('Pasted JSON is invalid: "galleries" array not found.');
}
// Use the first available GUID from the pasted data as the default for images
classicGuid = classicData.galleries[0]?.classicGuid || classicData.siteId;
} else if (guidInput) {
// Check if this is the new import_ format
if (guidInput.startsWith('import_')) {
if (statusMessage) statusMessage.textContent = 'Fetching data via import_ format...';
const importGuid = guidInput.substring(7); // Remove 'import_' prefix
const jsonUrl = `https://hydra.neonsky.app/json-imports/${guidInput}.json`;
const response = await fetch(jsonUrl);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status} for ${jsonUrl}`);
classicData = await response.json();
if (!classicData.galleries || !Array.isArray(classicData.galleries)) {
throw new Error('Fetched import JSON is invalid: "galleries" array not found.');
}
// HYBRID APPROACH: Also fetch the original GUID JSON for image metadata
if (statusMessage) statusMessage.textContent = 'Fetching image metadata from original JSON...';
const originalJsonUrl = `https://cdn.neonsky.app/sites:site_${importGuid}.json`;
try {
const originalResponse = await fetch(originalJsonUrl);
if (originalResponse.ok) {
const originalData = await originalResponse.json();
// Get the default language from site configuration
let defaultLanguageID = 'en'; // fallback
if (originalData.languages && Array.isArray(originalData.languages)) {
const defaultLang = originalData.languages.find(lang => lang.isDefault === true);
if (defaultLang) {
defaultLanguageID = defaultLang.id;
}
}
// Merge image metadata from original JSON into import JSON galleries
if (originalData.galleries && Array.isArray(originalData.galleries)) {
classicData.galleries = classicData.galleries.map(importGallery => {
// Find matching gallery in original data by name or categoryID
const originalGallery = originalData.galleries.find(orig =>
orig.title === importGallery.title || // Primary match by title
orig.name === importGallery.title || // Fallback: orig.name matches import.title
orig.categoryID === importGallery.categoryID // Fallback: categoryID match
);
if (originalGallery && originalGallery.images && Array.isArray(originalGallery.images)) {
// Process all images from original gallery with metadata extraction
const processedImages = originalGallery.images.map(originalImage => {
// Apply EXACT same metadata processing logic as Edition Publisher
let title = originalImage.title || '';
let dateline = originalImage.dateline || '';
let caption = originalImage.caption || '';
// Check languageVersions array for metadata (this is where most data is stored)
if (originalImage.languageVersions && originalImage.languageVersions.length > 0) {
// Look for site's default language first, then fall back to first available
let langData = originalImage.languageVersions.find(lang => lang.languageID === defaultLanguageID);
if (!langData) {
langData = originalImage.languageVersions[0];
}
// For language-enabled JSON files, prioritize languageVersions data
// Only fall back to top-level if languageVersions data is empty
if (!title || title.trim() === '') title = langData.title || '';
if (!dateline || dateline.trim() === '') dateline = langData.dateline || '';
if (!caption || caption.trim() === '') caption = langData.caption || '';
}
// Convert Flash TEXTFORMAT to HTML (properly escaped for template literals)
let description = caption;
if (caption && caption.includes('TEXTFORMAT')) {
try {
// Use properly escaped HTML characters for template literal context
description = caption
.replace(/\<TEXTFORMAT[^\>]*\>/g, '')
.replace(/\<\/TEXTFORMAT\>/g, '')
.replace(/\<P[^\>]*\>/g, '\<p\>')
.replace(/\<\/P\>/g, '\<\/p\>')
.replace(/\<FONT[^\>]*\>/g, '')
.replace(/\<\/FONT\>/g, '')
.replace(/\<A HREF="([^"]*)"[^\>]*\>/g, '\<a href="$1" target="_blank"\>')
.replace(/\<\/A\>/g, '\<\/a\>')
.replace(/\<U\>/g, '\<u\>')
.replace(/\<\/U\>/g, '\<\/u\>')
.replace(/\<B\>/g, '\<strong\>')
.replace(/\<\/B\>/g, '\<\/strong\>')
.replace(/\<I\>/g, '\<em\>')
.replace(/\<\/I\>/g, '\<\/em\>')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/\s+/g, ' ')
.trim();
} catch (error) {
console.warn('TEXTFORMAT conversion failed, using original caption:', error);
description = caption;
}
}
// Prepend dateline to description if it exists (convert to uppercase)
if (dateline) {
const uppercaseDateline = dateline.toUpperCase();
description = `${uppercaseDateline} ${description}`.trim();
}
// Apply description fixes
description = cleanDescriptionLeadingLines(description);
description = fixMultilineLinks(description);
description = convertPlainTextLinks(description);
const imageEntry = {
// Match EXACT structure from Edition Publisher hydraImageEntry
image: originalImage.image || originalImage.urls?.original || '',
caption: description, // Use processed description as caption
description: description, // ALSO set description field (like Edition Publisher)
title: title,
'alt-text': title, // Use title as alt-text like Edition Publisher
link: originalImage.urls?.original || '',
'link-text': originalImage.urls?.original || '',
isPreview: false,
filename: originalImage.filename || '',
originalUrl: originalImage.image || originalImage.urls?.original || '',
dateline: dateline
};
return imageEntry;
});
return {
...importGallery, // Keep import gallery structure (order, settings, etc.)
images: processedImages // Replace with processed original images
};
}
return importGallery;
});
}
} else {
console.warn('Could not fetch original JSON for metadata, using import data only');
}
} catch (error) {
console.warn('Error fetching original JSON for metadata:', error);
}
// Extract original GUID for image path lookups
// If importGuid has a suffix (e.g., "4bd5ebf7bda9d-all"), extract the base GUID
// GUIDs are typically 13 characters, so split on common separators
let baseGuid = importGuid;
const guidMatch = importGuid.match(/^([a-f0-9]{13})(?:-.*)?$/i);
if (guidMatch) {
baseGuid = guidMatch[1];
} else {
// Fallback: try to extract first 13-character hex string
const hexMatch = importGuid.match(/^([a-f0-9]{13})/i);
if (hexMatch) {
baseGuid = hexMatch[1];
}
}
// Use the base GUID for image path lookups (e.g., /images/filename.jpg)
classicGuid = baseGuid;
console.log('Successfully loaded hybrid import data with import GUID:', importGuid);
console.log('Using base GUID for image paths:', classicGuid);
} else {
// Standard GUID format - existing logic
if (statusMessage) statusMessage.textContent = 'Fetching data via GUID...';
classicGuid = guidInput.startsWith('GUID=') ? guidInput.substring(5) : guidInput;
console.log('Detected standard GUID format, GUID:', classicGuid);
const jsonUrl = `https://storage.neonsky.app/sites:site_${classicGuid}.json`;
console.log('Fetching classic JSON from:', jsonUrl);
const response = await fetch(jsonUrl);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status} for ${jsonUrl}`);
classicData = await response.json();
if (!classicData.galleries || !Array.isArray(classicData.galleries)) {
throw new Error('Fetched JSON is invalid: "galleries" array not found.');
}
console.log('Successfully loaded classic data with GUID:', classicGuid);
}
} else {
alert('Please either paste site JSON or enter a Classic GUID to import.');
if (statusEl) statusEl.style.display = 'none';
return;
}
const galleriesForImport = classicData.galleries;
if (statusMessage) statusMessage.textContent = `Found ${galleriesForImport.length} items to import...`;
// import_ format: galleries come from DB/import JSON, not classic site - treat as new Hydra NDJSON galleries
const isImportFormat = !!(guidInput && guidInput.startsWith('import_'));
// --- HIERARCHY RECONSTRUCTION LOGIC ---
const currentSiteId = window.Parameters?.siteId || siteId;
let submenuParentId = parentId;
const tempNewItems = [];
const originalIdToNewIdMap = {};
// Create the optional top-level submenu if requested
if (createSubmenu) {
const submenuItem = {
id: Date.now(),
title: submenuTitle,
isSubmenu: true,
isCollapsed: true,
visible: true,
parentId: parentId,
siteId: currentSiteId,
children: []
};
galleries.push(submenuItem);
submenuParentId = submenuItem.id;
}
// --- Pass 1: Create new items with correct data and map old IDs to new IDs ---
for (let index = 0; index < galleriesForImport.length; index++) {
const classicItem = galleriesForImport[index];
const newItemId = Date.now() + index + 100;
originalIdToNewIdMap[classicItem.id] = newItemId;
if (progressBar) progressBar.style.width = `${40 + Math.round(index * 50 / galleriesForImport.length)}%`;
if (statusMessage) statusMessage.textContent = `Processing item ${index + 1} of ${galleriesForImport.length}...`;
const title = classicItem.title || classicItem.languageVersions?.[0]?.title || classicItem.name || `Item ${classicItem.categoryID}`;
let slug;
// If pasting from JSON, *always* regenerate the slug from the title to ensure it's clean.
// Otherwise (for GUID import), trust the existing slug if it's there.
if (pastedJson) {
slug = normalizeNameForComparison(title);
} else if (classicItem.slug && classicItem.slug.trim() !== '') {
slug = classicItem.slug;
} else {
slug = normalizeNameForComparison(title);
}
const finalSlug = ensureUniqueSlug(slug || 'gallery', [...galleries, ...tempNewItems]); // Ensure uniqueness
// Determine the GUID to use for this specific item's image paths
const itemGuid = classicItem.classicGuid || classicGuid;
let newItem;
// --- Check if it's an External URL ---
if (classicItem.isExternal === true && classicItem.url) {
console.log(`Creating external URL menu item: "${title}" -> ${classicItem.url}`);
newItem = {
id: newItemId,
title,
url: classicItem.url,
isExternal: true,
visible: classicItem.visible !== false,
parentId: null,
siteId: currentSiteId,
slug: finalSlug,
importSource: 'external-url'
};
}
// --- Check if it's a Page and build its elements ---
else if (classicItem.isPage === true || classicItem.type === 3 || classicItem.behaviorID === 3) {
const pageUniqueId = `page_${newItemId}`;
let pageElements = [];
// =================================================================
// ENHANCED: Handle pageElements for both pasted JSON and import_guid.json format
// =================================================================
if (classicItem.pageElements && Array.isArray(classicItem.pageElements)) {
console.log(`Using existing pageElements for page: "${title}"`);
// Check if we have both pageElements and images - create two-column layout
if (classicItem.images && classicItem.images.length > 0) {
console.log(`Creating two-column layout for page: "${title}" with ${classicItem.images.length} images`);
// Create metadata element
pageElements.push({
id: newItemId + 1000, type: "metadata", title: "Metadata", visible: true, position: 0,
metaTitle: classicItem.meta?.title || title,
metaDescription: classicItem.meta?.description || "",
metaKeywords: classicItem.meta?.keywords || ""
});
// Create two-column container
const leftColumnElements = [];
const rightColumnElements = [];
// Add images to left column
const slides = classicItem.images.map((img) => {
const imageFilename = (img.filename || `image_${Date.now()}`) + (img.metadata?.extension || '.jpg');
const imageKey = `${itemGuid}/images/${imageFilename}`;
const imageUrl = `https://storage.neonsky.app/${imageKey}`;
return {
imageUrl,
imageKey,
imageFilename,
imageType: img.metadata?.mimetype || 'image/jpeg',
caption: cleanDescriptionLeadingLines(fixMultilineLinks(convertPlainTextLinks(img.caption || ''))),
title: img.title || '',
byline: img.byline || '',
dateline: img.dateline || '',
altText: img.metadata?.alt_text || ''
};
});
leftColumnElements.push({
id: newItemId + 3100, type: "slideshow", title: "Page Images", visible: true, position: 0,
slides, slideDuration: 5000, transitionDuration: 500, slideshowWidth: 88, slideshowHeight: 50, showFullImages: true
});
// Add text content to right column
classicItem.pageElements.forEach((el, elIndex) => {
if (el.type === 'text' && el.content) {
rightColumnElements.push({
id: newItemId + 3200 + elIndex, type: "text", title: "Text Block", visible: true, position: elIndex,
textContent: el.content, textWidth: 70
});
}
});
const columns = [
{ id: newItemId + 3300, hAlign: "right", vAlign: "middle", elements: leftColumnElements },
{ id: newItemId + 3400, hAlign: "left", vAlign: "middle", elements: rightColumnElements }
];
pageElements.push({
id: newItemId + 3000, type: "column-container", title: "Two Column Layout", visible: true, position: 1,
columns,
backgroundSlideshow: { enabled: false, slides: [], slideDuration: 5000, transitionDuration: 500, slideshowHeight: 50, showFullImages: false, showSingleRandomImage: false },
backgroundVideo: { enabled: false, videoUrl: '', videoUrls: [], randomizeVideo: false, videoHeight: 50 }
});
} else {
// No images, just transform the pageElements as before
pageElements = classicItem.pageElements.map((el, elIndex) => {
const elementId = Date.now() + index + 1000 + elIndex;
// Handle import_guid.json format (type + content)
if (el.type && el.content) {
if (el.type === 'text') {
return {
id: elementId,
type: 'text',
title: 'Text Block',
visible: true,
position: elIndex,
textContent: el.content,
textWidth: 100
};
} else if (el.type === 'metadata') {
return {
id: elementId,
type: 'metadata',
title: 'Metadata',
visible: true,
position: elIndex,
metaTitle: el.metaTitle || title,
metaDescription: el.metaDescription || '',
metaKeywords: el.metaKeywords || ''
};
}
}
// Handle existing format (already has id, position, etc.)
return {
...el,
id: elementId,
position: el.position !== undefined ? el.position : elIndex
};
});
}
} else {
// Otherwise (for GUID-based import), generate the page structure from scratch.
console.log(`Generating new pageElements for page: "${title}"`);
pageElements.push({
id: newItemId + 1000, type: "metadata", title: "Metadata", visible: true, position: 0,
metaTitle: classicItem.meta?.title || title,
metaDescription: classicItem.meta?.description || "",
metaKeywords: classicItem.meta?.keywords || ""
});
// Add images if they exist in the page
if (classicItem.images && classicItem.images.length > 0) {
const slides = classicItem.images.map((img) => {
const imageFilename = (img.filename || `image_${Date.now()}`) + (img.metadata?.extension || '.jpg');
const imageKey = `${itemGuid}/images/${imageFilename}`;
const imageUrl = `https://storage.neonsky.app/${imageKey}`;
return {
imageUrl,
imageKey,
imageFilename,
imageType: img.metadata?.mimetype || 'image/jpeg',
caption: cleanDescriptionLeadingLines(fixMultilineLinks(convertPlainTextLinks(img.caption || ''))),
title: img.title || '',
byline: img.byline || '',
dateline: img.dateline || '',
altText: img.metadata?.alt_text || ''
};
});
pageElements.push({
id: newItemId + 1500, type: "slideshow", title: "Page Images", visible: true, position: 1,
slides, slideDuration: 5000, transitionDuration: 500, slideshowWidth: 100, slideshowHeight: 60, showFullImages: true
});
}
pageElements.push({
id: newItemId + 2000, type: "text", title: "Text Block Spacer", visible: true, position: 2,
textContent: "<p> </p><p><br></p>", textWidth: 100
});
const columns = [];
const leftColumnElements = [];
const rightColumnElements = [];
if (classicItem.images && classicItem.images.length > 0) {
const slides = classicItem.images.map((img) => {
const imageFilename = (img.filename || `image_${Date.now()}`) + (img.metadata?.extension || '.jpg');
const imageKey = `${itemGuid}/images/${imageFilename}`;
const imageUrl = `https://storage.neonsky.app/${imageKey}`;
// Preserve all image metadata including captions, titles, etc.
return {
imageUrl,
imageKey,
imageFilename,
imageType: img.metadata?.mimetype || 'image/jpeg',
// Preserve caption information - handle both direct and language version storage patterns
caption: cleanDescriptionLeadingLines(fixMultilineLinks(convertPlainTextLinks(img.caption || img.languageVersions?.[0]?.caption || ''))),
title: img.title || img.languageVersions?.[0]?.title || '',
byline: img.byline || img.languageVersions?.[0]?.byline || '',
dateline: img.dateline || img.languageVersions?.[0]?.dateline || '',
altText: img.metadata?.alt_text || '',
// Preserve any additional metadata
metaTitle: img.metadata?.meta_title || '',
metaDescription: img.metadata?.meta_description || '',
metaKeywords: img.metadata?.meta_keywords || '',
// Preserve gallery-specific data
sortOrder: img.galleryData?.sortOrder || 0,
linkURL: img.galleryData?.linkURL || null,
linkTarget: img.galleryData?.linkTarget || '_self'
};
});
leftColumnElements.push({
id: newItemId + 3100, type: "slideshow", title: "Image Slideshow", visible: true, position: 0,
slides, slideDuration: 5000, transitionDuration: 500, slideshowWidth: 88, slideshowHeight: 50, showFullImages: true
});
}
let rawDetails = classicItem.details || classicItem.languageVersions?.[0]?.details || "";
if (rawDetails && typeof rawDetails === 'string') {
// =================================================================
// CHANGE 2: Fix for "Invalid regular expression flags" error.
// Changed from /.../gi literal to new RegExp('...', 'gi') constructor
// to prevent syntax errors.
// =================================================================
let processedText = rawDetails.replace(new RegExp('<TEXTFORMAT.*?>', 'gi'), '').replace(new RegExp('</TEXTFORMAT>', 'gi'), '');
processedText = processedText.replace(new RegExp('<P ALIGN=".*?"', 'gi'), '<p').replace(new RegExp('</P>', 'gi'), '</p>');
processedText = processedText.replace(new RegExp('<FONT.*?>', 'gi'), '').replace(new RegExp('</FONT>', 'gi'), '');
processedText = processedText.replace(new RegExp('<B>', 'gi'), '<strong>').replace(new RegExp('</B>', 'gi'), '</strong>');
processedText = processedText.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
// Apply description fixes to processed text
processedText = cleanDescriptionLeadingLines(processedText);
processedText = fixMultilineLinks(processedText);
processedText = convertPlainTextLinks(processedText);
rightColumnElements.push({
id: newItemId + 3200, type: "text", title: "Text Block Content", visible: true, position: 0,
textContent: processedText, textWidth: 70
});
}
columns.push({ id: newItemId + 3300, hAlign: "right", vAlign: "middle", elements: leftColumnElements });
columns.push({ id: newItemId + 3400, hAlign: "left", vAlign: "middle", elements: rightColumnElements });
pageElements.push({
id: newItemId + 3000, type: "column-container", title: "Imported Columns", visible: true, position: 2,
columns,
backgroundSlideshow: { enabled: false, slides: [], slideDuration: 5000, transitionDuration: 500, slideshowHeight: 50, showFullImages: false, showSingleRandomImage: false },
backgroundVideo: { enabled: false, videoUrl: '', videoUrls: [], randomizeVideo: false, videoHeight: 50 }
});
}
newItem = {
id: newItemId, title, isPage: true, isIntegrated: true, isSubmenu: false, pageId: pageUniqueId, visible: classicItem.visible !== false, parentId: null,
siteId: currentSiteId, slug: finalSlug, url: `/${finalSlug}`, pageElements,
classicCategoryId: classicItem.categoryID, classicGuid: itemGuid, importSource: 'classic-page'
};
} else if (classicItem.isFolder === true) {
// --- Handle folders from import_guid.json format ---
console.log(`Creating folder: "${title}"`);
newItem = {
id: newItemId,
title,
isFolder: true,
isCollapsed: classicItem.isCollapsed !== false,
visible: classicItem.visible !== false,
parentId: null,
siteId: currentSiteId,
slug: finalSlug,
importSource: 'folder'
};
} else {
// --- This is a Gallery ---
const galleryPageId = generatePageId();
// --- ENHANCED: Try to fetch and include actual image data with captions ---
let enhancedGalleryOptions = {
manualCollectionName: `GUID=${itemGuid}`,
categoryId: classicItem.categoryID,
importedFromClassic: true,
siteId: currentSiteId,
pageId: galleryPageId
};
// --- ENHANCED: Preserve gallery display settings from Classic JSON ---
if (classicItem.galleryOptions) {
console.log(`Preserving gallery settings for: ${title}`);
// Preserve layout and display settings
const preservedSettings = [
'startInSingles', 'layoutType', 'columns', 'spacing',
'showDescription', 'showFilename', 'displayAllInfo',
'navigationMode', 'showTextBlock', 'fixedHeroImage',
'zoomInLightbox', 'lightboxOnMobile', 'fadeDuration',
'fadeInDuration', 'descriptionTextColor', 'gridImageOverlayColor',
'gridImageOverlayOpacity', 'lightboxBgColor', 'lightboxBgOpacity',
'lightboxCloseColor', 'lightboxArrowColor', 'useTitles',
'useLinks', 'desktopTitleDisplayMode', 'titleTextAlign',
'showDescriptionInOverlay', 'includeRolloverImageInOverlay',
'filterMenuEnabled', 'filterMenuCollectionNames', 'filterMenuTitles',
'filterMenuStyle', 'rolloverSwap', 'rolloverCollectionNames',
'openMultipleLightboxes', 'batchSize', 'autoplaySingles',
'autoplayDuration', 'autoplayTransition', 'autoplayRandomStart'
];
preservedSettings.forEach(setting => {
if (classicItem.galleryOptions[setting] !== undefined) {
enhancedGalleryOptions[setting] = classicItem.galleryOptions[setting];
}
});
// Preserve text content
if (classicItem.galleryOptions.horizontalScrollerText) {
enhancedGalleryOptions.horizontalScrollerText = classicItem.galleryOptions.horizontalScrollerText;
}
// Preserve JSON collections if present
if (classicItem.galleryOptions.jsonCollections) {
enhancedGalleryOptions.jsonCollections = classicItem.galleryOptions.jsonCollections;
} else {
// Create jsonCollections structure with processed image metadata
const collectionKey = 'GUID=' + itemGuid;
const processedImages = classicItem.images || [];
enhancedGalleryOptions.jsonCollections = {
[collectionKey]: {
isClassicCollection: true,
guid: collectionKey,
metadata: processedImages,
totalItems: processedImages.length
}
};
console.log('Created jsonCollections for ' + title + ' with ' + processedImages.length + ' images');
// Build and try to upload NDJSON for this gallery.
// If upload succeeds, switch to metadata = {} so frontend fetches NDJSON.
// Skip NDJSON generation for galleries with no images to avoid creating empty files
if (processedImages.length > 0) {
try {
console.log('Building and uploading NDJSON for gallery:', title, '(' + processedImages.length + ' images)');
const ndjsonContent = buildHydraClassicNdjson(itemGuid, processedImages);
console.log('Generated NDJSON content length:', ndjsonContent.length, 'characters');
const uploadResult = await uploadNdjsonToTigris(currentSiteId, galleryPageId, ndjsonContent);
if (uploadResult && uploadResult.success) {
try {
// import_ format: treat as new Hydra NDJSON gallery (not classic-dependent)
if (isImportFormat) {
const hydraCollectionKey = 'GUID=' + currentSiteId;
enhancedGalleryOptions.manualCollectionName = hydraCollectionKey;
enhancedGalleryOptions.jsonCollections = {
[hydraCollectionKey]: {
isClassicCollection: false,
guid: hydraCollectionKey,
metadata: {},
totalItems: processedImages.length
}
};
enhancedGalleryOptions.siteAlias = window.Parameters?.SiteAlias || window.location?.hostname || currentSiteId;
enhancedGalleryOptions.initialPageUuid = galleryPageId;
enhancedGalleryOptions.isHydra = true;
enhancedGalleryOptions.flyIoSaveTimestamp = Date.now();
console.log('NDJSON uploaded for import_ gallery "' + title + '" - configured as Hydra NDJSON (not classic)');
} else {
enhancedGalleryOptions.jsonCollections[collectionKey].metadata = {};
enhancedGalleryOptions.jsonCollections[collectionKey].totalItems = processedImages.length;
}
// Store dual upload metadata if Irys was successful
if (uploadResult.irysResult && uploadResult.irysResult.txId) {
enhancedGalleryOptions.txId = uploadResult.irysResult.txId;
enhancedGalleryOptions.permaURL = uploadResult.irysResult.txId; // Plain txId for racing
enhancedGalleryOptions.isPerma = true;
enhancedGalleryOptions.uploadedTo = uploadResult.uploadedTo;
enhancedGalleryOptions.fileSize = uploadResult.fileSize;
enhancedGalleryOptions.siteId = currentSiteId;
enhancedGalleryOptions.pageId = galleryPageId;
console.log('✅ Dual upload complete - Irys txId:', uploadResult.irysResult.txId);
console.log(' Uploaded to:', uploadResult.uploadedTo.join(' + '));
} else {
console.log('✅ Single upload (Fly.io only) - No Irys data');
}
} catch (applyErr) {
console.warn('Failed to switch metadata to {} after NDJSON upload:', applyErr);
}
} else {
console.warn('NDJSON upload failed for "' + title + '"; keeping inline metadata array');
}
} catch (e) {
console.warn('NDJSON build/upload skipped for "' + title + '" due to error:', e);
}
} else {
console.log('Skipping NDJSON generation for "' + title + '" - no images to process');
}
}
// Preserve additional metadata
if (classicItem.galleryOptions.siteAlias) {
enhancedGalleryOptions.siteAlias = classicItem.galleryOptions.siteAlias;
}
if (classicItem.galleryOptions.initialPageUuid) {
enhancedGalleryOptions.initialPageUuid = classicItem.galleryOptions.initialPageUuid;
}
if (classicItem.galleryOptions.initialPageAlias) {
enhancedGalleryOptions.initialPageAlias = classicItem.galleryOptions.initialPageAlias;
}
if (classicItem.galleryOptions.timestamp) {
enhancedGalleryOptions.timestamp = classicItem.galleryOptions.timestamp;
}
} else if (isImportFormat && (classicItem.images || []).length > 0) {
// import_ format without galleryOptions: still create NDJSON for new Hydra galleries
const processedImages = classicItem.images || [];
const collectionKey = 'GUID=' + itemGuid;
enhancedGalleryOptions.jsonCollections = {
[collectionKey]: {
isClassicCollection: true,
guid: collectionKey,
metadata: processedImages,
totalItems: processedImages.length
}
};
console.log('Created jsonCollections for import_ gallery (no galleryOptions):', title, '(' + processedImages.length + ' images)');
try {
const ndjsonContent = buildHydraClassicNdjson(itemGuid, processedImages);
const uploadResult = await uploadNdjsonToTigris(currentSiteId, galleryPageId, ndjsonContent);
if (uploadResult && uploadResult.success) {
const hydraCollectionKey = 'GUID=' + currentSiteId;
enhancedGalleryOptions.manualCollectionName = hydraCollectionKey;
enhancedGalleryOptions.jsonCollections = {
[hydraCollectionKey]: {
isClassicCollection: false,
guid: hydraCollectionKey,
metadata: {},
totalItems: processedImages.length
}
};
enhancedGalleryOptions.siteAlias = window.Parameters?.SiteAlias || window.location?.hostname || currentSiteId;
enhancedGalleryOptions.initialPageUuid = galleryPageId;
enhancedGalleryOptions.isHydra = true;
enhancedGalleryOptions.flyIoSaveTimestamp = Date.now();
if (uploadResult.irysResult?.txId) {
enhancedGalleryOptions.txId = uploadResult.irysResult.txId;
enhancedGalleryOptions.permaURL = uploadResult.irysResult.txId;
enhancedGalleryOptions.isPerma = true;
enhancedGalleryOptions.uploadedTo = uploadResult.uploadedTo || ['fly'];
}
console.log('NDJSON uploaded for import_ gallery "' + title + '" (no galleryOptions) - configured as Hydra NDJSON');
}
} catch (e) {
console.warn('NDJSON build/upload skipped for import_ gallery "' + title + '":', e);
}
}
// Note: For import from pasted JSON, we don't include image data to avoid overwriting existing captions
// Image data is only included when importing from Classic GUID directly
console.log(`Gallery ${title} imported with structure and settings preserved (image data not included to avoid overwriting existing captions)`);
newItem = {
id: newItemId, title, url: `/${finalSlug}`, isIntegrated: true, isPage: false, isSubmenu: false, visible: classicItem.visible !== false, parentId: null,
siteId: currentSiteId, pageId: galleryPageId, classicCategoryId: classicItem.categoryID, classicGuid: itemGuid,
importSource: 'classic-gallery', slug: finalSlug, normalizedName: finalSlug,
galleryOptions: enhancedGalleryOptions
};
}
tempNewItems.push(newItem);
}
// --- Pass 2: Link children to parents using the ID map ---
tempNewItems.forEach(newItem => {
const originalId = Object.keys(originalIdToNewIdMap).find(key => originalIdToNewIdMap[key] === newItem.id);
const originalItem = galleriesForImport.find(g => String(g.id) === String(originalId));
if (originalItem && originalItem.parentId) {
const newParentId = originalIdToNewIdMap[originalItem.parentId];
if (newParentId) {
newItem.parentId = newParentId;
const parentItem = tempNewItems.find(p => p.id === newParentId);
if (parentItem) {
parentItem.isSubmenu = true;
parentItem.isCollapsed = true;
}
} else {
newItem.parentId = submenuParentId;
}
} else {
newItem.parentId = submenuParentId;
}
});
if (replaceMenu) {
console.log("Replacing existing menu structure.");
// If replacing, overwrite the entire galleries array.
galleries = tempNewItems;
} else {
console.log("Appending to existing menu structure.");
// If not replacing, push the new items to the existing array.
galleries.push(...tempNewItems);
}
if (progressBar) progressBar.style.width = '90%';
if (statusMessage) statusMessage.textContent = 'Saving changes...';
// Pass import flag to skip localStorage for large import operations
await saveGalleries({ isImport: true });
if (progressBar) progressBar.style.width = '100%';
if (statusMessage) statusMessage.textContent = `Successfully imported ${tempNewItems.length} items!`;
if (statusEl) statusEl.classList.remove('importing');
setTimeout(() => {
renderGalleries();
toggleImportClassicForm();
if (progressBar) progressBar.style.width = '0';
if (statusEl) statusEl.style.display = 'none';
if (confirm('Import complete! Would you like to reload the page to ensure everything is displayed correctly?')) {
window.location.reload();
}
}, 1000);
} catch (error) {
console.error('Error importing classic galleries:', error);
if (statusMessage) statusMessage.textContent = `Error: ${error.message}`;
if (progressBar) {
progressBar.style.width = '100%';
progressBar.style.backgroundColor = '#dc3545';
}
if (statusEl) statusEl.classList.remove('importing');
}
}
/**
* Improved toggleImportClassicForm - Closes other forms first
*/
function toggleImportClassicForm() {
// First check if user is logged in and in edit mode
if (!isAuthenticated || !isAdmin || !window.isInEditMode()) {
alert('You must be logged in as an admin and in edit mode to import galleries.');
return;
}
const formId = 'importClassicForm';
const form = document.getElementById(formId);
if (!form) {
console.error('Import form element not found');
return;
}
// If this form is already open, just close everything
if (window.currentOpenForm === formId) {
closeAllForms();
return;
}
// Otherwise close all forms and open this one
closeAllForms();
// Show the form
form.style.display = 'block';
// Use setTimeout to allow the display change to take effect before adding the class
setTimeout(() => {
form.classList.add('visible');
}, 10);
// Populate parent options
const parentSelect = document.getElementById('importParent');
if (parentSelect) {
parentSelect.innerHTML = '<option value="">Top Level (No Parent)</option>';
// Add all galleries that could be parents (including submenus)
galleries.forEach(gallery => {
// Only include items that are not already marked as imported
if (!gallery.importSource) {
parentSelect.innerHTML += `<option value="${gallery.id}">${gallery.title}</option>`;
}
});
}
// Set default values
const submenuTitle = document.getElementById('submenuTitle');
if (submenuTitle && !submenuTitle.value) {
submenuTitle.value = 'Classic Galleries';
}
// Reset any previous import status
const statusEl = document.getElementById('importStatus');
if (statusEl) {
statusEl.style.display = 'none';
const progressBar = statusEl.querySelector('.progress-bar');
if (progressBar) {
progressBar.style.width = '0';
progressBar.style.backgroundColor = '#4682B4'; // Reset to blue
}
}
// Set this as the current open form
window.currentOpenForm = formId;
}
// Add event listener for the radio buttons to adjust height when page is selected
document.addEventListener('DOMContentLoaded', function() {
const galleryTypeRadios = document.querySelectorAll('input[name="galleryType"]');
galleryTypeRadios.forEach(radio => {
radio.addEventListener('change', function() {
const addForm = document.getElementById('addForm');
if (addForm && addForm.classList.contains('visible')) {
const isPageSelected = this.value === 'page';
// Give more height for page type
if (isPageSelected) {
addForm.style.maxHeight = '600px';
} else {
// For other types, measure content height
const contentHeight = addForm.scrollHeight + 30;
addForm.style.maxHeight = Math.max(contentHeight, 500) + 'px';
}
}
});
});
});
/**
* Improved toggleStyleEditor - Closes other forms first
*/
function toggleStyleEditor(display = false, event) {
const formId = 'menuStyleEditor';
const overlayOpen = window.MenuStyleCustomizer && window.MenuStyleCustomizer._overlayOpen;
if (display === false || window.currentOpenForm === formId || overlayOpen) {
closeAllForms();
return;
}
closeAllForms();
if (window.MenuStyleCustomizer && typeof window.MenuStyleCustomizer.toggleOverlay === 'function') {
window.MenuStyleCustomizer.toggleOverlay();
}
}
/**
* This function closes all forms when escape key is pressed
*/
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && window.currentOpenForm) {
if (closeTopmostStyleCustomizerChildOverlay()) {
return;
}
closeAllForms();
}
});
/**
* This function closes all forms when clicking outside any form
*/
document.addEventListener('click', function(e) {
if (!window.currentOpenForm) return;
// Keep Style Customizer and its nested panels open during in-overlay interactions.
if (e.target.closest(
'.pe-style-customizer-overlay, .pe-custom-code-overlay, .pe-palette-explorer-overlay, .pe-font-explorer-overlay, .pe-style-history-overlay, .sc-style-history-overlay, .pe-unsaved-changes-overlay, #colorMixerPopup, #artPaletteBtn, #exploreFontsBtn, #customCodeBtn, #colorMixerDot'
)) {
return;
}
if (e.target.closest('.image-uploader-overlay')) {
return;
}
const currentForm = document.getElementById(window.currentOpenForm);
if (!currentForm) {
if (e.target.closest(
'.pe-style-customizer-overlay, .pe-custom-code-overlay, .pe-palette-explorer-overlay, .pe-font-explorer-overlay, .pe-style-history-overlay, .sc-style-history-overlay, .pe-unsaved-changes-overlay, #colorMixerPopup'
)) {
return;
}
closeAllForms();
return;
}
const isInsideForm = currentForm.contains(e.target);
const isFormToggleButton = e.target.closest('[onclick*="toggle"]') !== null;
if (isInsideForm || isFormToggleButton) {
return;
}
closeAllForms();
});
// Override the original functions with our improved versions
window.toggleAddForm = toggleAddForm;
window.toggleStyleEditor = toggleStyleEditor;
window.toggleMetadataEditor = toggleMetadataEditor;
window.toggleSidebarElementForm = toggleSidebarElementForm;
window.toggleImportClassicForm = toggleImportClassicForm;
window.closeAllForms = closeAllForms;
window.closeTopmostStyleCustomizerChildOverlay = closeTopmostStyleCustomizerChildOverlay;
/**
* Opens the edit form for a gallery item.
* (UPDATED to include Meta Title and Meta Description fields, removed Parent Menu)
* @param {number} id - The ID of the gallery item.
* @param {Event} event - The click event.
*/
function editGallery(id, event) {
event.stopPropagation(); // Prevent event bubbling
const gallery = findGalleryById(galleries, id);
const li = getGalleryTreeListItem(id, event);
if (gallery && li) {
const showUrlField = gallery.isPage || gallery.isIntegrated;
const showExternalUrlField = gallery.isExternal;
/** Sidebar meta title/description apply to gallery routes only; pages use the page metadata element. */
const showSidebarSeoFields = !gallery.isPage;
const currentSlug = gallery.slug || (gallery.url && gallery.url.startsWith('/') ? gallery.url.substring(1) : '');
const currentExternalUrl = gallery.url || '';
// Clear existing content and rebuild form to ensure event listeners are fresh if needed
li.innerHTML = ''; // Clear previous form if any
const formDiv = document.createElement('div');
formDiv.className = 'edit-form';
formDiv.innerHTML = `
<div class="form-group">
<label>Title:</label>
<input type="text" value="${gallery.title || ''}" class="edit-title">
</div>
${showUrlField ? `
<div class="form-group">
<label>URL Slug:</label>
<input type="text" value="${currentSlug}" class="edit-slug" placeholder="e.g., my-gallery">
<small>Path after domain name (e.g., yoursite.com<b>/my-gallery</b>). Use lowercase letters, numbers, and hyphens.</small>
</div>
` : ''}
${showExternalUrlField ? `
<div class="form-group">
<label>External URL:</label>
<input type="text" value="${currentExternalUrl}" class="edit-external-url" placeholder="https://example.com">
<small>Full URL for the external link (e.g., https://example.com/page).</small>
</div>
` : ''}
${showSidebarSeoFields ? `
<div class="form-group">
<label>Meta Title (Optional):</label>
<input type="text" value="${gallery.metaTitle || ''}" class="edit-meta-title" placeholder="Title for search results (if different from main title)">
<small>Overrides site title for this item in search results/browser tabs.</small>
</div>
<div class="form-group">
<label>Meta Description (Optional):</label>
<textarea class="edit-meta-description" placeholder="Description for search results (150-160 chars recommended)">${gallery.metaDescription || ''}</textarea>
<small>Overrides site description for this item in search results.</small>
</div>
` : `
<div class="form-group">
<p class="text-muted" style="margin: 0 0 8px 0; font-size: 12px; line-height: 1.4;">
Search title and description for this page are set in <strong>Page → metadata</strong> (the metadata block on the page), not here.
</p>
</div>
`}
<div class="form-group">
<label style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" class="edit-hide-menu" ${gallery.hideMenuOnPage ? 'checked' : ''} style="width: auto; margin-bottom: 0;">
Hide menu when viewing this page
</label>
<small>Allows for a full-screen page or gallery layout. Menu remains visible in edit mode.</small>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" class="edit-password-protected" ${gallery.passwordProtected ? 'checked' : ''} style="width: auto; margin-bottom: 0;">
Password protect this page
</label>
<small>Visitors will need to enter a password to access this page.</small>
</div>
<div class="form-group password-field" style="display: ${gallery.passwordProtected ? 'block' : 'none'};">
<label>Password:</label>
<input type="password" value="${gallery.password || ''}" class="edit-password" placeholder="Enter password">
<small>Password visitors must enter to access this page.</small>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" class="edit-no-index" ${(() => {
// Explicit setting takes priority; otherwise default to checked for hidden non-home pages
if (gallery.noIndex === true) return 'checked';
if (gallery.noIndex === false) return '';
return (gallery.visible === false && !gallery.isHomePage) ? 'checked' : '';
})()} style="width: auto; margin-bottom: 0;">
Hide from search engines (noindex)
</label>
<small>When checked, search engines will not index this page. Automatically enabled for hidden pages — uncheck to allow indexing even when hidden.</small>
</div>
<div class="edit-form-actions">
<button class="btn btn-primary" id="saveEditBtn-${id}">Save</button>
<button class="btn" id="cancelEditBtn-${id}">Cancel</button>
</div>
<br>
`;
li.appendChild(formDiv); // Append the new form
// Add event listeners programmatically
const saveButton = formDiv.querySelector(`#saveEditBtn-${id}`);
if (saveButton) {
saveButton.addEventListener('click', (e) => saveEdit(id, e));
}
const cancelButton = formDiv.querySelector(`#cancelEditBtn-${id}`);
if (cancelButton) {
cancelButton.addEventListener('click', () => renderGalleries());
}
// Add password protection toggle functionality
const passwordProtectedCheckbox = formDiv.querySelector('.edit-password-protected');
const passwordField = formDiv.querySelector('.password-field');
if (passwordProtectedCheckbox && passwordField) {
passwordProtectedCheckbox.addEventListener('change', function() {
passwordField.style.display = this.checked ? 'block' : 'none';
});
}
li.classList.add('edit-mode');
} else {
console.error("Could not find gallery or list item for ID:", id);
}
}
/**
* Resolve the correct sidebar #galleryTree li for an id. Horizontal dropdowns also use
* li[data-id] (.horizontal-submenu-item); document.querySelector would return that node first
* and break nested folder toggles in edit mode while horizontal layout is on.
* @param {number} id - Gallery ID
* @param {Event|null} event - Optional click event (preferred: closest li inside #galleryTree)
* @returns {HTMLElement|null}
*/
function getGalleryTreeListItem(id, event) {
var numericId = typeof id === 'number' ? id : parseInt(String(id), 10);
if (isNaN(numericId)) return null;
var galleryTreeEl = document.getElementById('galleryTree');
if (event && event.target && galleryTreeEl) {
var clicked = event.target.closest('li[data-id]');
if (clicked && galleryTreeEl.contains(clicked)) {
var cid = parseInt(clicked.getAttribute('data-id') || '', 10);
if (cid === numericId) return clicked;
}
}
if (galleryTreeEl) {
var scoped = galleryTreeEl.querySelector('li[data-id="' + numericId + '"]');
if (scoped) return scoped;
}
return document.querySelector('li[data-id="' + numericId + '"]');
}
/**
* Handles click on a folder item to show/hide its contents
* @param {number} id - The gallery ID
* @param {Event} event - The click event
*/
function toggleFolder(id, event) {
// Ensure the event doesn't interfere with drag operations
if (event) {
event.stopPropagation();
}
const gallery = galleries.find(g => g.id === id);
if (!gallery) {
return;
}
if (gallery.isFolder !== true && gallery.isSubmenu !== true) {
return;
}
// Toggle the collapsed state
gallery.isCollapsed = !gallery.isCollapsed;
// Find the list item in the DOM (#galleryTree first — not horizontal li.horizontal-submenu-item)
const listItem = getGalleryTreeListItem(id, event);
if (!listItem) {
return;
}
// Toggle the expanded class FIRST - this is important for CSS to work correctly
listItem.classList.toggle('expanded', !gallery.isCollapsed);
// Keep ARIA in sync for folder label + chevron (screen readers / keyboard)
const folderExpanded = !gallery.isCollapsed;
listItem.querySelectorAll('.menu-item.folder, .submenu-toggle').forEach((el) => {
el.setAttribute('aria-expanded', folderExpanded ? 'true' : 'false');
});
// Force a reflow after toggling the class to ensure CSS recalculates
void listItem.offsetHeight;
// Find the nested list - SIMPLIFIED: Check if we're in sidebar first, then horizontal
// CRITICAL: Check #galleryTree first - this is the definitive indicator of sidebar layout
// Use contains() instead of closest() because closest() only checks ancestors
const galleryTree = document.getElementById('galleryTree');
const isInGalleryTree = galleryTree && galleryTree.contains(listItem);
const isInSidebar = listItem.closest('.sidebar') || listItem.closest('#sidebar') || isInGalleryTree;
// Only consider it horizontal menu if it's explicitly in .horizontal-menu-container AND not in galleryTree
const isInHorizontalMenu = listItem.closest('.horizontal-menu-container') && !isInGalleryTree && !isInSidebar;
let nestedList = null;
// If we're in sidebar (#galleryTree), ONLY look for ol.nested-sortable - ignore horizontal structures
// CRITICAL: Prioritize sidebar detection - if we're in galleryTree, we're definitely in sidebar
if (isInGalleryTree) {
nestedList = listItem.querySelector('ol.nested-sortable');
// Also check direct children as fallback
if (!nestedList) {
const directChildren = Array.from(listItem.children);
nestedList = directChildren.find(child => child.tagName === 'OL' && child.classList.contains('nested-sortable'));
}
} else if (isInSidebar) {
// Fallback: if we're in .sidebar but not in #galleryTree, still treat as sidebar
nestedList = listItem.querySelector('ol.nested-sortable');
if (!nestedList) {
const directChildren = Array.from(listItem.children);
nestedList = directChildren.find(child => child.tagName === 'OL' && child.classList.contains('nested-sortable'));
}
}
// For horizontal layout, check horizontal structures (ONLY if NOT in sidebar/galleryTree)
if (!nestedList && isInHorizontalMenu && !isInGalleryTree && !isInSidebar) {
nestedList = listItem.querySelector('.horizontal-submenu');
if (!nestedList) {
nestedList = listItem.querySelector('.horizontal-dropdown');
}
if (!nestedList) {
// For horizontal nested dropdowns, they're appended to document.body, not the list item
// They're linked via data-flyout-id attribute
const flyoutId = listItem.getAttribute('data-flyout-id');
if (flyoutId) {
// Look specifically for div.horizontal-nested-dropdown, not the list item itself
const allElementsWithId = document.querySelectorAll('[data-flyout-id="' + flyoutId + '"]');
// Find the actual dropdown div, not the list item
const flyout = Array.from(allElementsWithId).find(el =>
el.classList.contains('horizontal-nested-dropdown') && el !== listItem
);
if (flyout) {
nestedList = flyout.querySelector('.horizontal-nested-list') || flyout;
}
}
}
if (!nestedList) {
nestedList = listItem.querySelector('.horizontal-nested-dropdown');
}
}
// Fallback: try all selectors if not found yet
if (!nestedList) {
nestedList = listItem.querySelector('ol.nested-sortable');
}
if (!nestedList) {
// Debug: Log the actual DOM structure to understand what's there
// Try to find any nested structure at all
const anyNested = listItem.querySelector('ol, ul, div[class*="submenu"], div[class*="nested"], div[class*="dropdown"]');
if (anyNested) {
nestedList = anyNested; // Use it anyway
}
// Last resort: Check if nested list exists as a direct child but wasn't found by querySelector
// This can happen if the element was removed/re-added or if there's a timing issue
const directChildren = Array.from(listItem.children);
const olChild = directChildren.find(child => child.tagName === 'OL');
if (olChild) {
nestedList = olChild;
}
}
if (!nestedList) {
// If the nested list doesn't exist, we need to create it
// This can happen if children aren't rendered until the folder is opened
// First try flat array
let gallery = galleries.find(g => g.id === id);
// If gallery not found or has no children in flat array, build tree structure to find it
if (!gallery || !gallery.children || gallery.children.length === 0) {
// Build tree structure from flat array
const buildTree = (items, parentId = null) => {
return items
.filter(item => item.parentId === parentId)
.map(item => ({
...item,
children: buildTree(items, item.id)
}));
};
const galleryTree = buildTree(galleries);
// Recursive function to find gallery in tree
const findInTree = (items) => {
for (const item of items) {
if (item.id === id) {
return item;
}
if (item.children && item.children.length > 0) {
const found = findInTree(item.children);
if (found) return found;
}
}
return null;
};
const treeGallery = findInTree(galleryTree);
if (treeGallery) {
gallery = treeGallery;
}
}
if (gallery && gallery.children && gallery.children.length > 0) {
// Determine nesting level from list item
const currentLevel = parseInt(listItem.getAttribute('data-nesting-level') || '0', 10);
const nextLevel = currentLevel + 1;
// Create the nested list element
nestedList = document.createElement('ol');
nestedList.id = 'gallery-children-' + id;
nestedList.className = 'nested-sortable';
if (nextLevel > 0) {
nestedList.classList.add('nested-level-' + nextLevel);
}
// Render the children - ensure we're in the right context (not in a horizontal dropdown wrapper)
gallery.children.forEach(child => {
const childHtml = renderGalleryItem(child, nextLevel, 20);
const tempDiv = document.createElement('div');
tempDiv.innerHTML = childHtml;
const childLi = tempDiv.querySelector('li');
if (childLi) {
nestedList.appendChild(childLi);
}
});
// CRITICAL: Only check for horizontal dropdowns if we're actually in a horizontal menu context
// If we're in #galleryTree (sidebar), skip all horizontal menu logic
const galleryTree = document.getElementById('galleryTree');
const isInGalleryTree = galleryTree && galleryTree.contains(listItem);
const isInSidebar = listItem.closest('.sidebar') || listItem.closest('#sidebar') || isInGalleryTree;
if (!isInGalleryTree && !isInSidebar) {
const isInHorizontalMenu = listItem.closest('.horizontal-menu-container');
if (isInHorizontalMenu) {
const horizontalDropdown = listItem.closest('.horizontal-dropdown, .horizontal-nested-dropdown');
if (horizontalDropdown && horizontalDropdown !== listItem) {
horizontalDropdown.style.setProperty('display', 'block', 'important');
horizontalDropdown.style.setProperty('visibility', 'visible', 'important');
horizontalDropdown.style.setProperty('opacity', '1', 'important');
}
}
}
// CRITICAL: Ensure list item itself is visible and has correct display
listItem.style.setProperty('display', 'list-item', 'important');
listItem.style.setProperty('visibility', 'visible', 'important');
// CRITICAL: Ensure ALL parent elements up to the sidebar are visible
// BUT: Don't make .horizontal-menu-container visible - that's the entire horizontal menu!
let parent = listItem.parentElement;
let depth = 0;
while (parent && depth < 10) {
// Skip .horizontal-menu-container - we don't want to show the entire horizontal menu
if (parent.classList.contains('horizontal-menu-container')) {
parent = parent.parentElement;
depth++;
continue;
}
const parentStyles = window.getComputedStyle(parent);
if (parentStyles.display === 'none' || parentStyles.visibility === 'hidden' || parentStyles.opacity === '0') {
if (parentStyles.display === 'none') {
parent.style.setProperty('display', 'block', 'important');
}
if (parentStyles.visibility === 'hidden') {
parent.style.setProperty('visibility', 'visible', 'important');
}
if (parentStyles.opacity === '0') {
parent.style.setProperty('opacity', '1', 'important');
}
}
// Stop at sidebar or galleryTree
if (parent.classList.contains('sidebar') || parent.id === 'galleryTree' || parent.id === 'sidebar') {
break;
}
parent = parent.parentElement;
depth++;
}
// Append directly to the list item (not to any wrapper)
// Make sure we're not appending to a hidden parent
listItem.appendChild(nestedList);
// Immediately set the nested list to be visible since we're opening it
nestedList.style.setProperty('display', 'block', 'important');
nestedList.style.setProperty('visibility', 'visible', 'important');
nestedList.style.setProperty('opacity', '1', 'important');
nestedList.style.setProperty('height', 'auto', 'important');
nestedList.style.setProperty('max-height', 'none', 'important');
// Force immediate reflow after appending
void nestedList.offsetHeight;
void listItem.offsetHeight;
}
}
if (nestedList) {
if (!gallery.isCollapsed) {
// Show the nested list - ensure parent has expanded class FIRST
// CRITICAL: Add expanded class BEFORE any style operations
listItem.classList.add('expanded');
// Verify expanded class was added
if (!listItem.classList.contains('expanded')) {
listItem.className = listItem.className + ' expanded';
}
// CRITICAL: Ensure parent li is visible (it might be hidden by CSS)
const parentVisibility = window.getComputedStyle(listItem).visibility;
if (parentVisibility === 'hidden') {
listItem.style.setProperty('visibility', 'visible', 'important');
}
// Force a reflow to ensure CSS recalculates after adding expanded class
void listItem.offsetHeight;
void nestedList.offsetHeight;
// Show the nested list with !important to override CSS
// Set these BEFORE removing any existing inline styles to avoid flicker
nestedList.style.setProperty('display', 'block', 'important');
nestedList.style.setProperty('visibility', 'visible', 'important');
nestedList.style.setProperty('opacity', '1', 'important');
nestedList.style.setProperty('height', 'auto', 'important');
nestedList.style.setProperty('max-height', 'none', 'important');
nestedList.style.setProperty('overflow', 'visible', 'important');
// Also ensure the nested list is not clipped by parent overflow
// BUT: Don't modify .horizontal-menu-container overflow - that's the entire horizontal menu!
let checkParent = nestedList.parentElement;
let checkDepth = 0;
while (checkParent && checkDepth < 5) {
// Skip .horizontal-menu-container - we don't want to modify the entire horizontal menu
if (checkParent.classList.contains('horizontal-menu-container')) {
checkParent = checkParent.parentElement;
checkDepth++;
continue;
}
const parentOverflow = window.getComputedStyle(checkParent).overflow;
if (parentOverflow === 'hidden') {
checkParent.style.setProperty('overflow', 'visible', 'important');
}
if (checkParent.classList.contains('sidebar') || checkParent.id === 'galleryTree') {
break;
}
checkParent = checkParent.parentElement;
checkDepth++;
}
// Force another reflow after setting styles
void nestedList.offsetHeight;
const finalRect = nestedList.getBoundingClientRect();
// If the nested list has zero dimensions, it's not actually visible
if (finalRect.width === 0 || finalRect.height === 0) {
// Force all parent elements to be visible
// BUT: Don't modify .horizontal-menu-container - that's the entire horizontal menu!
let forceParent = nestedList.parentElement;
while (forceParent && forceParent !== document.body) {
// Skip .horizontal-menu-container - we don't want to show the entire horizontal menu
if (forceParent.classList.contains('horizontal-menu-container')) {
forceParent = forceParent.parentElement;
continue;
}
forceParent.style.setProperty('display', 'block', 'important');
forceParent.style.setProperty('visibility', 'visible', 'important');
forceParent.style.setProperty('opacity', '1', 'important');
forceParent.style.setProperty('overflow', 'visible', 'important');
if (forceParent.classList.contains('sidebar') || forceParent.id === 'galleryTree') {
break;
}
forceParent = forceParent.parentElement;
}
void nestedList.offsetHeight;
}
// Check for any parent elements that might be hiding the nested list and fix them
let parent = nestedList.parentElement;
let depth = 0;
while (parent && depth < 5) {
const parentStyles = window.getComputedStyle(parent);
if (parentStyles.display === 'none' || parentStyles.visibility === 'hidden' || parentStyles.opacity === '0') {
// Fix hidden parent elements
if (parentStyles.display === 'none') {
parent.style.setProperty('display', 'block', 'important');
}
if (parentStyles.visibility === 'hidden') {
parent.style.setProperty('visibility', 'visible', 'important');
}
if (parentStyles.opacity === '0') {
parent.style.setProperty('opacity', '1', 'important');
}
}
parent = parent.parentElement;
depth++;
}
} else {
// Hide the nested list
nestedList.style.setProperty('display', 'none', 'important');
nestedList.style.setProperty('visibility', 'hidden', 'important');
nestedList.style.setProperty('opacity', '0', 'important');
}
// Force multiple reflows to ensure the browser applies all changes
void listItem.offsetHeight;
void nestedList.offsetHeight;
void listItem.offsetWidth;
void nestedList.offsetWidth;
// Force a more aggressive repaint by temporarily toggling a class
// This is especially important for sidebar layout where CSS rules might be interfering
listItem.classList.add('force-reflow');
void listItem.offsetHeight;
listItem.classList.remove('force-reflow');
// Force reflow again after class manipulation
void listItem.offsetHeight;
void nestedList.offsetHeight;
// Use requestAnimationFrame with multiple frames to ensure visual update
// This ensures the browser has time to recalculate layout and repaint
requestAnimationFrame(() => {
void nestedList.offsetHeight;
// Second frame to ensure all CSS has been recalculated
requestAnimationFrame(() => {
void nestedList.offsetHeight;
// Verify the final state
const finalDisplay = window.getComputedStyle(nestedList).display;
const finalVisibility = window.getComputedStyle(nestedList).visibility;
// If still not visible, force one more repaint cycle
if (!gallery.isCollapsed && finalDisplay === 'none') {
nestedList.style.setProperty('display', 'block', 'important');
nestedList.style.setProperty('visibility', 'visible', 'important');
void nestedList.offsetHeight;
requestAnimationFrame(() => {
void nestedList.offsetHeight;
});
}
});
});
}
// Find the submenu toggle icon and rotate it
const toggleIcon = listItem.querySelector('.toggle-icon');
if (toggleIcon) {
toggleIcon.classList.toggle('rotated', !gallery.isCollapsed);
}
// Folder open/closed is a local UI preference — do not POST the full site config.
try {
const folderStates = JSON.parse(localStorage.getItem('folder_states') || '{}');
folderStates[id] = !gallery.isCollapsed;
localStorage.setItem('folder_states', JSON.stringify(folderStates));
} catch (e) {
console.error('Error saving folder state to localStorage:', e);
}
}
window.toggleFolder = toggleFolder;
/**
* Toggle password protection for a gallery/page
* @param {number} id - The gallery ID
* @param {Event} event - The click event
*/
function toggleGalleryPasswordProtection(id, event) {
event.stopPropagation();
const gallery = findGalleryById(galleries, id);
if (!gallery) {
console.error("Could not find gallery for password protection toggle, ID:", id);
return;
}
if (gallery.passwordProtected) {
// Remove password protection
if (confirm('Remove password protection from this page?')) {
gallery.passwordProtected = false;
delete gallery.password;
saveGalleries();
renderGalleries();
}
} else {
// Add password protection
const password = prompt('Enter a password for this page:');
if (password && password.trim()) {
gallery.passwordProtected = true;
gallery.password = password.trim();
saveGalleries();
renderGalleries();
}
}
}
window.toggleGalleryPasswordProtection = toggleGalleryPasswordProtection;
/**
* Saves the edited gallery item details.
* (UPDATED to handle Meta Title and Meta Description, removed Parent Menu logic)
* @param {number} id - The ID of the gallery item.
* @param {Event} event - The click event.
*/
function saveEdit(id, event) {
event.stopPropagation();
const li = getGalleryTreeListItem(id, event);
if (!li) {
console.error("Could not find list item for saveEdit, ID:", id);
return;
}
const titleInput = li.querySelector('.edit-title');
const slugInput = li.querySelector('.edit-slug');
const externalUrlInput = li.querySelector('.edit-external-url');
const metaTitleInput = li.querySelector('.edit-meta-title');
const metaDescriptionInput = li.querySelector('.edit-meta-description');
const hideMenuInput = li.querySelector('.edit-hide-menu');
const passwordProtectedInput = li.querySelector('.edit-password-protected');
const passwordInput = li.querySelector('.edit-password');
const noIndexInput = li.querySelector('.edit-no-index');
const title = titleInput ? titleInput.value.trim() : '';
const newSlugRaw = slugInput ? slugInput.value.trim() : null;
const externalUrl = externalUrlInput ? externalUrlInput.value.trim() : '';
const metaTitle = metaTitleInput ? metaTitleInput.value.trim() : '';
const metaDescription = metaDescriptionInput ? metaDescriptionInput.value.trim() : '';
const hideMenu = hideMenuInput ? hideMenuInput.checked : false;
const passwordProtected = passwordProtectedInput ? passwordProtectedInput.checked : false;
const password = passwordInput ? passwordInput.value.trim() : '';
// noIndex is stored explicitly so it survives visibility changes without reverting
const noIndex = noIndexInput ? noIndexInput.checked : undefined;
const gallery = findGalleryById(galleries, id);
if (!gallery) {
console.error("Could not find gallery object for saveEdit, ID:", id);
renderGalleries();
return;
}
if (!title && !gallery.isSpacer) {
alert('Please enter a title');
return;
}
// Validate password protection
if (passwordProtected && !password) {
alert('Please enter a password when password protection is enabled');
return;
}
gallery.title = title;
// Sidebar SEO fields are only shown for galleries; do not overwrite stored values when saving a page.
if (!gallery.isPage) {
gallery.metaTitle = metaTitle;
gallery.metaDescription = metaDescription;
}
gallery.hideMenuOnPage = hideMenu;
// Persist the explicit noIndex choice so it survives future visibility toggles
if (noIndex !== undefined) gallery.noIndex = noIndex;
// Save password protection settings
gallery.passwordProtected = passwordProtected;
if (passwordProtected) {
gallery.password = password;
} else {
delete gallery.password; // Remove password if protection is disabled
}
if ((gallery.isPage || gallery.isIntegrated) && newSlugRaw !== null) {
const originalSlug = gallery.slug || (gallery.url && gallery.url.startsWith('/') ? gallery.url.substring(1) : '');
let sanitizedSlug = slugify(newSlugRaw);
if (!sanitizedSlug) {
sanitizedSlug = slugify(gallery.title);
}
if (sanitizedSlug !== originalSlug) {
const otherGalleries = galleries.filter(g => g.id !== id);
gallery.slug = ensureUniqueSlug(sanitizedSlug, otherGalleries);
gallery.url = `/${gallery.slug}`;
} else {
if (gallery.url !== `/${gallery.slug}`) {
gallery.url = `/${gallery.slug}`;
}
}
}
// Update external URL if this is an external link
if (gallery.isExternal && externalUrl) {
gallery.url = externalUrl;
}
saveGalleries();
renderGalleries();
// If the currently active gallery was just edited, re-apply menu visibility
if (gallery.id === activeGalleryId) {
const bodyEl = document.body;
if (shouldApplyMenuHiddenForEmbedOrGallery(gallery)) {
bodyEl.classList.add('menu-hidden-on-page');
} else {
bodyEl.classList.remove('menu-hidden-on-page');
}
}
}
// Helper function to check if a gallery is an ancestor of another
function hasAncestor(galleryId, ancestorId) {
if (galleryId === ancestorId) return true;
const gallery = galleries.find(g => g.id === galleryId);
if (!gallery || !gallery.parentId) return false;
if (gallery.parentId === ancestorId) return true;
return hasAncestor(gallery.parentId, ancestorId);
}
function deleteGallery(id, event) {
event.stopPropagation();
const gallery = galleries.find(g => g.id === id);
const title = gallery && (gallery.title || gallery.slug || 'this item') || 'this item';
if (!confirm('Are you sure you want to delete "' + title + '"?')) {
return;
}
const overlay = showDeletionOverlay();
overlay.updateMessage('Saving...');
galleries = galleries.filter(g => g.id !== id);
if (typeof window !== 'undefined') {
window.galleries = galleries;
}
if (activeGalleryId === id) {
activeGalleryId = null;
const frame = document.getElementById('galleryFrame');
if (frame) frame.src = '';
}
saveGalleries()
.then(function(success) {
renderGalleries();
overlay.hide();
if (success === false) {
alert('Failed to delete. Please try again.');
}
})
.catch(function(err) {
overlay.hide();
console.error('Error deleting item:', err);
alert('Failed to delete. Please try again.');
});
}
function debugNestedStructure() {
console.group('Current Gallery Structure');
function printGallery(gallery, level = 0) {
const indent = ' '.repeat(level);
console.log(`${indent}${gallery.title} (ID: ${gallery.id}, Parent: ${gallery.parentId || 'none'})`);
if (gallery.children && gallery.children.length > 0) {
gallery.children.forEach(child => printGallery(child, level + 1));
}
}
// Create a tree structure for debugging
const galleryTree = createGalleryTree(galleries);
galleryTree.forEach(gallery => printGallery(gallery));
console.groupEnd();
}
function toggleHome(id, event) {
event.stopPropagation();
console.log('Setting home page to gallery:', id);
// Find gallery
const gallery = galleries.find(g => g.id === id);
if (!gallery) return;
// Check if this gallery is already set as home
const alreadyHome = gallery.isHomePage === true;
// Set all galleries to not be the home page
galleries.forEach(g => {
g.isHomePage = false;
});
// Toggle this gallery's home status - if it was already home, we're unsetting it
gallery.isHomePage = !alreadyHome;
// Save changes
saveGalleries();
// Update UI
renderGalleries();
// If this gallery is now the home page, display a message
if (gallery.isHomePage) {
const title = gallery.title || 'This page';
showSuccessMessage(`${title} is now set as the Home page`);
}
}
// Function to toggle gallery visibility
function toggleVisibility(id, event) {
event.stopPropagation();
const gallery = galleries.find(g => g.id === id);
if (gallery) {
// Toggle the visibility
gallery.visible = gallery.visible === false ? true : false;
// Save the change to server
saveGalleries();
// Update UI - important to toggle the class on the button itself
const toggle = event.currentTarget;
if (toggle) {
if (gallery.visible === false) {
toggle.classList.add('hidden');
// Update the SVG icon
toggle.querySelector('svg').innerHTML = '<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path><line x1="1" y1="1" x2="23" y2="23"></line>';
} else {
toggle.classList.remove('hidden');
// Update the SVG icon
toggle.querySelector('svg').innerHTML = '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle>';
}
}
// Update parent row class (#galleryTree row, not horizontal submenu li)
const listItem = getGalleryTreeListItem(id, event);
if (listItem) {
if (gallery.visible === false) {
listItem.classList.add('hidden-gallery');
} else {
listItem.classList.remove('hidden-gallery');
}
}
// Only clear iframe if not in edit mode
if (id === activeGalleryId && gallery.visible === false && !isEditing) {
document.getElementById('galleryFrame').src = '';
}
// Re-render menus based on current layout
const currentLayout = document.body.classList.contains('menu-layout-horizontal') ? 'horizontal' :
document.body.classList.contains('menu-layout-top') ? 'top' : 'sidebar';
if (currentLayout === 'horizontal' && typeof window.renderHorizontalMenu === 'function') {
window.renderHorizontalMenu();
}
// Also re-render the sidebar gallery tree to ensure hidden-gallery class is properly applied
// This is especially important when horizontal layout is active but we're viewing the sidebar in edit mode
if (typeof window.renderGalleries === 'function') {
window.renderGalleries();
} else if (typeof renderGalleries === 'function') {
renderGalleries();
}
}
}
/**
* Simplified loadGallery function that follows the same content swap pattern
* @param {number} id - The gallery ID
* @param {Event} event - Optional event object
*/
// Enhanced loadGallery function with script cleanup
async function loadGallery(id, event) {
// Stop event propagation if provided; preventDefault keeps <a href> SPA navigation
if (event) {
if (typeof event.preventDefault === 'function') event.preventDefault();
event.stopPropagation();
}
const galleryContainer = document.querySelector('.gallery-container');
if (galleryContainer) {
// Apply immediate hiding styles
galleryContainer.style.opacity = '0';
}
console.log('Loading gallery with ID:', id);
// Find gallery by ID
const gallery = findGalleryById(galleries, id); // Assuming `galleries` is accessible
if (!gallery) {
console.warn('No gallery found with ID:', id);
if (galleryContainer) {
galleryContainer.style.opacity = '1';
}
return;
}
// Check if this is a page, if so use loadPage instead
if (isPageMenuItem(gallery)) {
// Use the loadPage function defined in this scope (or window.loadPage if you prefer)
return loadPage(id, event);
}
// Skip submenu items
if (gallery.isSubmenu) {
console.log('Gallery is submenu, skipping URL update');
toggleSubmenu(id, event || { stopPropagation: () => {} }); // Assuming toggleSubmenu is available
if (galleryContainer) {
galleryContainer.style.opacity = '1';
} // Show container
return;
}
console.log('Found gallery:', gallery.title);
// CRITICAL: Set active gallery ID consistently in both global and window scope
console.log('Setting active gallery ID to:', id, '(Previous value:', (window.activeGalleryId || activeGalleryId), ')');
window.activeGalleryId = id;
activeGalleryId = id; // Ensure both variables are synchronized
// Save reference to which gallery we're loading (for later verification)
window.currentLoadingGalleryId = id;
// CRITICAL: Thorough cleanup with await to ensure completion
if (typeof window.removeGalleryScriptsWithPause === 'function') {
await window.removeGalleryScriptsWithPause();
}
// Clear all existing content from gallery container
if (!galleryContainer) {
console.error('Gallery container not found');
return;
}
// Force clear all content
galleryContainer.innerHTML = '';
// CRITICAL: Double-check active gallery ID before updating UI
if (window.activeGalleryId !== id || activeGalleryId !== id) {
console.warn('Active gallery ID changed unexpectedly before UI update, restoring to:', id);
window.activeGalleryId = id;
activeGalleryId = id;
}
// Apply menu visibility based on the gallery's setting and edit mode
const bodyEl = document.body;
if (shouldApplyMenuHiddenForEmbedOrGallery(gallery)) {
bodyEl.classList.add('menu-hidden-on-page');
} else {
bodyEl.classList.remove('menu-hidden-on-page'); // Ensure menu is visible if in edit mode or not hidden
}
// Update UI state using explicit window function references or local fallbacks
if (typeof window.updateActiveStates === 'function') {
window.updateActiveStates();
} else if (typeof updateActiveStates === 'function') {
updateActiveStates();
}
if (typeof window.updateMobileTitle === 'function') {
window.updateMobileTitle();
} else if (typeof updateMobileTitle === 'function') {
updateMobileTitle();
}
if (typeof window.closeMobileMenu === 'function') {
window.closeMobileMenu();
} else if (typeof closeMobileMenu === 'function') {
closeMobileMenu();
}
// Update URL with gallery slug
if (typeof window.updateURLWithGallerySlug === 'function') {
window.updateURLWithGallerySlug(gallery);
} else if (typeof updateURLWithGallerySlug === 'function') {
updateURLWithGallerySlug(gallery);
}
// Create gallery container and load content
const neonGalleryContainer = document.createElement('div');
neonGalleryContainer.id = 'neon-gallery-container';
neonGalleryContainer.className = 'gallery-direct-content';
neonGalleryContainer.setAttribute('data-gallery-id', gallery.id.toString());
neonGalleryContainer.style.width = '100%';
neonGalleryContainer.style.height = '100%';
galleryContainer.appendChild(neonGalleryContainer);
// Add a cache-busting parameter to ensure script is freshly loaded
const timestamp = Date.now();
// Set up gallery parameters
window.Parameters = {
SiteAlias: window.location.hostname,
InitialPageUuid: gallery.pageId,
InitialPageAlias: gallery.slug || slugify(gallery.title), // Assuming slugify is available
isInEditor: window.isEditing || false, // Assuming window.isEditing is available
siteId: window.siteId || gallery.siteId || '', // Assuming window.siteId is available
isHydra: true,
galleryInstanceId: timestamp,
// Added loadedGalleryId as per your original function
loadedGalleryId: gallery.id
};
// Debug: Check what galleryOptions contains
console.error('🔍 INDEX.TS DEBUG - Gallery:', gallery.title, 'has isPerma:', gallery.galleryOptions?.isPerma, 'permaURL:', gallery.galleryOptions?.permaURL, 'loadTxId:', gallery.galleryOptions?.loadTxId, 'loadPermaURL:', gallery.galleryOptions?.loadPermaURL);
// Set up gallery config
window.neonGalleryConfig = {
useData: true,
useCDN: true,
version: 'live',
manualCollectionName: gallery.galleryOptions?.manualCollectionName || "mod",
layoutType: gallery.galleryOptions?.layoutType || "grid",
// Spread gallery options AFTER setting defaults to ensure current gallery's settings take precedence
// This ensures that only properties from the current gallery are included, not from previous galleries
...(gallery.galleryOptions || {}),
// CRITICAL: Explicitly include Load Network fields to ensure they're passed to gallery script
// These fields are required for the gallery to determine if it should use Load Network racing
loadTxId: gallery.galleryOptions?.loadTxId || null,
loadPermaURL: gallery.galleryOptions?.loadPermaURL || null,
// Cloudflare CDN NDJSON (unique id) — when present, gallery loads CDN-first (3s) then Fly
cdnNdjsonId: gallery.galleryOptions?.cdnNdjsonId || null,
cdnNdjsonUrl: gallery.galleryOptions?.cdnNdjsonUrl || null,
cdnNdjsonStableUrl: gallery.galleryOptions?.cdnNdjsonStableUrl || gallery.galleryOptions?.embedPointerUrl || null,
embedPointerUrl: gallery.galleryOptions?.embedPointerUrl || gallery.galleryOptions?.cdnNdjsonStableUrl || null,
cdnSaveTimestamp: gallery.galleryOptions?.cdnSaveTimestamp || null,
siteId: window.siteId || gallery.siteId || '', // Assuming window.siteId is available
// Added galleryId and galleryInstanceId as per your original function - these OVERRIDE spread
galleryId: gallery.id,
galleryInstanceId: timestamp
};
console.error('🔍 INDEX.TS DEBUG - After spread, window.neonGalleryConfig.isPerma:', window.neonGalleryConfig.isPerma, 'permaURL:', window.neonGalleryConfig.permaURL, 'loadTxId:', window.neonGalleryConfig.loadTxId, 'loadPermaURL:', window.neonGalleryConfig.loadPermaURL, 'cdnNdjsonId:', window.neonGalleryConfig.cdnNdjsonId);
// Create and add the gallery script with cache-busting
// Use Worker route (avoids CDN SSL issues)
const script = document.createElement('script');
const galleryScriptsBase = window.location.origin + '/gallery-scripts/';
script.src = galleryScriptsBase + 'neon-gallery-main-v260814-001.js'; // Using versioned file
script.setAttribute('data-gallery-id', gallery.id.toString()); // Keep data-gallery-id attribute
script.setAttribute('data-timestamp', timestamp.toString()); // Keep data-timestamp attribute
console.log('Loading gallery-main script from Worker:', script.src);
script.onerror = function() {
console.error('❌ Gallery script failed, retrying via proxy:', script.src);
const proxyUrl = script.src.replace('https://cdn.neonsky.app/', window.location.origin + '/cdn-proxy/');
script.src = proxyUrl;
script.onerror = function() {
console.error('❌ Proxy failed, trying storage:', proxyUrl);
script.src = script.src.replace('cdn.neonsky.app', 'storage.neonsky.app');
};
};
// Added script.onload from your original function
script.onload = function() {
if (window.currentLoadingGalleryId !== gallery.id) {
console.warn('Gallery ID mismatch! Expected:', gallery.id,
'Current:', window.currentLoadingGalleryId);
}
console.log(`Gallery script loaded for: ${gallery.title} (ID: ${gallery.id})`);
};
if (galleryContainer) {
setTimeout(() => {
galleryContainer.style.opacity = '1';
}, 50); // Small delay to ensure content is ready
}
console.log(`Loading fresh gallery script for: ${gallery.title}`);
document.body.appendChild(script);
}
function removeGalleryScriptsWithPause() {
return new Promise(resolve => {
console.log('Performing comprehensive gallery cleanup (v2 - refined selectors)');
// 1. Capture and clear any gallery data in localStorage (remains the same)
try {
const localStorageKeys = Object.keys(localStorage);
const galleryStorageKeys = localStorageKeys.filter(key =>
key.includes('gallery') ||
key.includes('neon') ||
key.includes('lightbox')
);
galleryStorageKeys.forEach(key => {
localStorage.removeItem(key);
});
} catch (e) {
console.warn('Error clearing localStorage:', e);
}
// @ts-ignore
window._galleryPageContext = null; // (remains the same)
// 3. Remove all gallery-specific CSS styles (remains the same)
const galleryStyles = document.querySelectorAll('style[data-gallery], link[href*="neon-gallery"]');
galleryStyles.forEach(style => {
if (style && style.parentNode) {
// console.log('Removing gallery style element'); // Console log can be verbose, optionally keep
style.parentNode.removeChild(style);
}
});
// 4. Remove lightbox elements (more specific selectors now)
// These are specific IDs and classes for lightbox components.
const lightboxComponentSelectors = [
'#neon-lightbox', // Main lightbox container by ID
'.neon-lightbox', // Alternative class for main container
'.lightbox-container',// General container class if used
// Add any other *specific* classes or IDs your lightbox system uses for its top-level elements
];
const lightboxElements = document.querySelectorAll(lightboxComponentSelectors.join(', '));
lightboxElements.forEach(lightboxEl => {
if (lightboxEl && lightboxEl.parentNode && lightboxEl !== document.body && lightboxEl !== document.documentElement) {
// console.log('Removing lightbox component:', lightboxEl.id || lightboxEl.className);
lightboxEl.parentNode.removeChild(lightboxEl);
}
});
// 5. Remove other gallery-specific DOM elements
// REMOVED: '[class*="lightbox"]', '[id*="lightbox"]' from this list
// The selector '[class*="neon-"]' is still broad but less likely to match body unless you add "neon-" class to body.
const galleryElementSelectors = [
'.neon-gallery-wrapper', '.gallery-container .fullscreen-overlay',
'.neon-gallery-modal', '.gallery-tooltip', '.neon-gallery-context-menu',
'.neon-thumbnails', '.neon-image', '.neon-caption',
'.neon-controls', '.neon-pagination',
'[class*="neon-"]', // BE CAUTIOUS: If this matches body or critical layout elements, it can cause issues.
'[data-gallery-id]', '[data-image-id]', '.image-grid', '.image-masonry'
// Ensure none of these selectors inadvertently match document.body or critical layout containers
];
const galleryElements = document.querySelectorAll(galleryElementSelectors.join(', '));
if (galleryElements.length > 0) {
galleryElements.forEach(el => {
// CRITICAL FIX: Add checks to ensure we don't remove body or html
if (el && el.parentNode && el !== document.body && el !== document.documentElement) {
// console.log('Removing gallery-specific DOM element:', el.id || el.className);
el.parentNode.removeChild(el);
} else if (el === document.body || el === document.documentElement) {
console.warn(`[CRITICAL WARNING] Attempt to remove document.body or document.documentElement blocked by selector: ${el.id || el.className}. Review your selectors.`);
}
});
}
// 6. Find and remove ALL gallery scripts (remains the same)
const scripts = document.querySelectorAll(
'script[src*="neon-gallery-main-hydra"], script[src*="neon-gallery-main-dev.js"], ' +
'script[src*="gallery"], script[data-gallery-id], script[data-gallery], script[data-timestamp]'
);
scripts.forEach(script => {
if (script && script.parentNode) {
// console.log('Removing script:', script.src || script.getAttribute('data-gallery-id'));
script.parentNode.removeChild(script);
}
});
// 6b. Specifically remove gallery options scripts (but NOT CSS - CSS is needed across galleries)
const optionsScripts = document.querySelectorAll(
'script[src*="galleryOptions"], script[data-options-js]'
);
optionsScripts.forEach(script => {
if (script && script.parentNode) {
console.log('Removing gallery options script:', script.src || script.getAttribute('data-options-js'));
script.parentNode.removeChild(script);
}
});
// NOTE: We intentionally do NOT remove gallery options CSS here
// The CSS is needed across galleries and doesn't cause conflicts like scripts do
setTimeout(() => {
const remainingScripts = document.querySelectorAll(
'script[src*="neon-gallery-main-hydra"], script[src*="neon-gallery-main-dev.js"], ' +
'script[src*="gallery"], script[data-gallery-id], script[data-gallery], script[data-timestamp]'
);
if (remainingScripts.length > 0) {
// console.warn(`[DEBUG] ${remainingScripts.length} gallery scripts still found after removal attempt.`);
}
}, 50);
// 7. Reset ALL known global state variables used by the gallery (remains the same)
const resetGlobals = [
'neonGalleryInitComplete', 'neonGalleryInitInProgress',
'neonGalleryLoaded', 'neonGalleryConfig', 'neonGalleryState',
'neonGalleryCache', 'neonGalleryImages', 'neonGallerySettings',
'neonLightbox', 'neonGalleryEventListeners', 'neonGalleryInstance',
'currentGalleryId', 'galleryData', 'imageCache', 'thumbnailCache',
];
resetGlobals.forEach(prop => {
// @ts-ignore
if (window[prop] !== undefined) {
// @ts-ignore
window[prop] = null;
}
});
try { delete window.__neonGalleryMainBootstrapWipeDone; } catch (e) { window.__neonGalleryMainBootstrapWipeDone = false; }
// 7b. Reset gallery options panel state
if (window._editorButtonVisibilityInterval) {
clearInterval(window._editorButtonVisibilityInterval);
window._editorButtonVisibilityInterval = null;
}
const editorBtn = document.getElementById('editorButton');
if (editorBtn) {
editorBtn.classList.add('gallery-options-force-hidden');
editorBtn.style.setProperty('display', 'none', 'important');
editorBtn.style.pointerEvents = '';
}
if (typeof window.syncGalleryOptionsButtonVisibility === 'function') {
window.syncGalleryOptionsButtonVisibility();
}
// Reset optionsVisible flag if it exists on window (for cross-module access)
// @ts-ignore
if (window.optionsVisible !== undefined) {
// @ts-ignore
window.optionsVisible = false;
}
// Reset options-related globals
const optionsGlobals = [
'generateOptionsUI', 'openOptionsOverlay', 'showOptionsPanel',
'hideOptionsPanel', 'toggleOptionsOverlay', 'optionsVisible'
];
optionsGlobals.forEach(prop => {
// @ts-ignore
if (window[prop] !== undefined && typeof window[prop] !== 'function') {
// Only reset non-function properties (like flags)
// Functions should remain available
// @ts-ignore
if (prop === 'optionsVisible') {
// @ts-ignore
window[prop] = false;
}
}
});
// Reset EnhancedGallerySystem so a pending initPromise cannot hang forever
// after scripts it was waiting on were just removed from the DOM.
if (window.EnhancedGallerySystem && typeof window.EnhancedGallerySystem.resetInitializationState === 'function') {
window.EnhancedGallerySystem.resetInitializationState();
}
// Reset PageManager page-embedded gallery tracking
window.neonGalleryScriptLoaded = false;
window.pageManagerGalleryInitBlocked = false;
if (window.pageManagerGalleryContainers) {
window.pageManagerGalleryContainers.clear();
}
// Remove orphaned lightbox keyboard listeners (survive script tag removal)
if (typeof window.removeAllLightboxKeyboardListeners === 'function') {
window.removeAllLightboxKeyboardListeners();
} else if (window.currentLightboxKeyboardListener) {
document.removeEventListener('keydown', window.currentLightboxKeyboardListener);
window.currentLightboxKeyboardListener = null;
window.lightboxKeyboardListenerActive = false;
}
// Short pause before resolving
setTimeout(resolve, 150);
});
}
// Fixed clearAddForm function with proper variable names
function clearAddForm() {
// Clear the title input
const titleInput = document.getElementById('galleryTitle');
if (titleInput) {
titleInput.value = '';
}
// Clear the URL input
const urlInput = document.getElementById('galleryUrl');
if (urlInput) {
urlInput.value = '';
}
// Reset the parent selection
const parentSelect = document.getElementById('galleryParent');
if (parentSelect) {
parentSelect.value = '';
}
// Reset radio buttons to Gallery (integrated) as default
const galleryRadio = document.querySelector('input[name="galleryType"][value="integrated"]');
if (galleryRadio) {
galleryRadio.checked = true;
}
// Hide URL input container when default is Gallery
const urlInputContainer = document.getElementById('urlInputContainer');
if (urlInputContainer) {
urlInputContainer.style.display = 'none';
} else {
console.log('URL input container not found');
}
}
/**
* Ensures window.galleries is synchronized with the global galleries variable
* Call this function after any modification to gallery settings
*
* @returns {boolean} True if synchronization was successful
*/
/**
* Enhanced synchronizeGalleries function that checks ALL galleries
* not just imported ones, and ensures settings are preserved
* @returns {boolean} Success indicator
*/
function synchronizeGalleries() {
console.log('Starting enhanced gallery synchronization...');
if (typeof galleries !== 'undefined' && Array.isArray(galleries)) {
// First check if window.galleries exists and is different from galleries
const needsSync = !window.galleries ||
!Array.isArray(window.galleries) ||
window.galleries.length !== galleries.length;
// ENHANCED: Check ALL galleries for options preservation, not just imported ones
if (!needsSync && window.galleries.length > 0) {
// Find all galleries with galleryOptions
const galleriesWithOptions = galleries.filter(g =>
g.galleryOptions && Object.keys(g.galleryOptions).length > 4
);
if (galleriesWithOptions.length > 0) {
// Check ALL galleries with substantial options, not just a sample
let syncNeeded = false;
// Use for loop so we can break early if we find a mismatch
for (let i = 0; i < galleriesWithOptions.length; i++) {
const gallery = galleriesWithOptions[i];
const windowGallery = window.galleries.find(g => g.id === gallery.id);
if (windowGallery) {
// Get options depth
const galleryOptionsDepth = gallery.galleryOptions ?
Object.keys(gallery.galleryOptions).length : 0;
const windowOptionsDepth = windowGallery.galleryOptions ?
Object.keys(windowGallery.galleryOptions).length : 0;
// If options depth differs significantly, we need to sync
if (Math.abs(galleryOptionsDepth - windowOptionsDepth) > 2) {
console.log(`Gallery sync needed: Options mismatch detected for ${gallery.title}`);
console.log(`Options count: global=${galleryOptionsDepth}, window=${windowOptionsDepth}`);
syncNeeded = true;
break; // No need to check more galleries
}
// Additional check: Look for specific important gallery settings that should be preserved
// Only do this check for galleries with substantial settings
if (galleryOptionsDepth > 5 && windowOptionsDepth > 5) {
// Critical keys that indicate gallery customization
const criticalKeys = ['columns', 'spacing', 'layoutType', 'startInSingles',
'autoplaySingles', 'autoplayRandomStart', 'lightboxBgColor'];
// Check if any critical keys are missing in window.galleries
const missingKeys = criticalKeys.filter(key =>
gallery.galleryOptions[key] !== undefined &&
windowGallery.galleryOptions[key] === undefined
);
if (missingKeys.length > 0) {
console.log(`Gallery sync needed: Critical settings missing in window.galleries for ${gallery.title}`);
console.log(`Missing keys: ${missingKeys.join(', ')}`);
syncNeeded = true;
break; // No need to check more galleries
}
}
}
}
// If we detected a need to sync, do it now
if (syncNeeded) {
// Ensure we preserve rich gallery settings and per-page custom HTML fields
window.galleries = galleries.map(gallery => {
const windowGallery = window.galleries.find(g => g.id === gallery.id);
if (!windowGallery) return gallery;
let merged = { ...gallery };
if (windowGallery.galleryOptions && gallery.galleryOptions) {
const globalOptionsDepth = Object.keys(gallery.galleryOptions).length;
const windowOptionsDepth = Object.keys(windowGallery.galleryOptions).length;
if (windowOptionsDepth > globalOptionsDepth) {
merged = {
...merged,
galleryOptions: {
...windowGallery.galleryOptions,
...gallery.galleryOptions
}
};
}
}
if (windowGallery.customPageHtmlR2Key || windowGallery.customPageHtmlEnabled) {
merged = {
...merged,
customPageHtmlEnabled: windowGallery.customPageHtmlEnabled,
customPageHtmlR2Key: windowGallery.customPageHtmlR2Key,
customPageHtmlFilename: windowGallery.customPageHtmlFilename
};
}
return merged;
});
console.log(`Synchronized window.galleries with global galleries (${galleries.length} items)`);
return true;
}
}
}
// If we determined sync is needed (or as a precaution), update window.galleries
if (needsSync || !window.galleries) {
if (window.galleries && Array.isArray(window.galleries)) {
galleries = galleries.map(function(gallery) {
const windowGallery = window.galleries.find(function(g) { return g && g.id === gallery.id; });
if (!windowGallery) return gallery;
if (windowGallery.customPageHtmlR2Key || windowGallery.customPageHtmlEnabled) {
return {
...gallery,
customPageHtmlEnabled: windowGallery.customPageHtmlEnabled,
customPageHtmlR2Key: windowGallery.customPageHtmlR2Key,
customPageHtmlFilename: windowGallery.customPageHtmlFilename
};
}
return gallery;
});
}
window.galleries = [...galleries]; // Create a shallow copy to ensure different reference
console.log(`Synchronized window.galleries with global galleries (${galleries.length} items)`);
} else {
console.log('Gallery synchronization check: No sync needed');
}
// Verify that all galleries with rich settings have their full options preserved
const richGalleries = galleries.filter(g =>
g.galleryOptions && Object.keys(g.galleryOptions).length > 5
);
console.log(`Status: Found ${richGalleries.length} galleries with rich settings`);
// Final verification - keep both arrays aligned without dropping page-level fields
if (window.galleries !== galleries) {
console.warn('References not identical after sync - merging page fields into global galleries');
if (window.galleries && Array.isArray(window.galleries)) {
galleries = galleries.map(function(gallery) {
const windowGallery = window.galleries.find(function(g) { return g && g.id === gallery.id; });
if (!windowGallery) return gallery;
return {
...gallery,
customPageHtmlEnabled: windowGallery.customPageHtmlEnabled ?? gallery.customPageHtmlEnabled,
customPageHtmlR2Key: windowGallery.customPageHtmlR2Key ?? gallery.customPageHtmlR2Key,
customPageHtmlFilename: windowGallery.customPageHtmlFilename ?? gallery.customPageHtmlFilename,
hideMenuOnPage: windowGallery.hideMenuOnPage ?? gallery.hideMenuOnPage
};
});
}
window.galleries = galleries;
return true;
}
return true;
} else {
console.warn('Cannot synchronize galleries: global galleries variable is undefined or not an array');
// If we have window.galleries but no global galleries, try to reverse-sync
if (window.galleries && Array.isArray(window.galleries) && window.galleries.length > 0) {
console.log('Attempting reverse sync: updating global galleries from window.galleries');
try {
// This is risky and should only happen in unusual circumstances
galleries = window.galleries;
return true;
} catch (e) {
console.error('Could not reverse-sync galleries:', e);
}
}
return false;
}
}
/**
* Loads NDJSON content from the original gallery
* @param {string} siteId - The site ID
* @param {string} pageId - The page ID of the original gallery
* @returns {Promise<string|null>} - The NDJSON content or null if not found/error
*/
async function loadGalleryNDJSON(siteId, pageId) {
console.error('[NDJSON Duplication] loadGalleryNDJSON called with siteId:', siteId, 'pageId:', pageId);
if (!siteId || !pageId) {
console.error('[NDJSON Duplication] ERROR: Cannot load NDJSON - missing siteId or pageId', { siteId: siteId, pageId: pageId });
return null;
}
try {
const ndjsonFilename = `${siteId}_${pageId}.ndjson`;
const url = `https://fly.storage.tigris.dev/ns-bridge-pub/${ndjsonFilename}`;
console.error('[NDJSON Duplication] Attempting to load NDJSON from:', url);
console.error('[NDJSON Duplication] Filename:', ndjsonFilename);
const response = await fetch(url);
console.error('[NDJSON Duplication] Fetch response status:', response.status, response.statusText);
if (!response.ok) {
if (response.status === 404) {
console.error('[NDJSON Duplication] ERROR: NDJSON file not found (404) - gallery may not have been published yet');
console.error('[NDJSON Duplication] Attempted URL:', url);
return null;
}
const errorText = await response.text().catch(() => 'Could not read error text');
console.error('[NDJSON Duplication] ERROR: Failed to load NDJSON:', response.status, response.statusText, errorText);
throw new Error(`Failed to load NDJSON: ${response.statusText}`);
}
const ndjsonContent = await response.text();
console.error('[NDJSON Duplication] SUCCESS: Loaded NDJSON, length:', ndjsonContent.length, 'characters');
console.error('[NDJSON Duplication] First 200 chars:', ndjsonContent.substring(0, 200));
return ndjsonContent;
} catch (error) {
console.error('[NDJSON Duplication] ERROR: Exception loading NDJSON:', error);
console.error('[NDJSON Duplication] Error message:', error.message);
console.error('[NDJSON Duplication] Error stack:', error.stack);
return null;
}
}
/**
* Duplicates and publishes NDJSON for a gallery
* @param {Object} originalGallery - The original gallery object
* @param {Object} newGallery - The new gallery object with new pageId
* @returns {Promise<boolean>} - Success indicator
*/
async function duplicateGalleryNDJSON(originalGallery, newGallery) {
console.error('[NDJSON Duplication] ===== STARTING duplicateGalleryNDJSON =====');
console.error('[NDJSON Duplication] Original gallery:', {
id: originalGallery.id,
title: originalGallery.title,
pageId: originalGallery.pageId,
classicGuid: originalGallery.classicGuid,
hasGalleryOptions: !!originalGallery.galleryOptions,
manualCollectionName: originalGallery.galleryOptions?.manualCollectionName,
isClassicCollection: originalGallery.galleryOptions?.isClassicCollection
});
console.error('[NDJSON Duplication] New gallery:', {
id: newGallery.id,
title: newGallery.title,
pageId: newGallery.pageId
});
try {
// Check if this is an NDJSON-based gallery (not classic collection)
// IMPORTANT: A gallery can have a GUID-style manualCollectionName but still use NDJSON files
// Only skip if it's explicitly marked as a classic collection OR has no pageId (which means no NDJSON)
// Having a pageId means it uses NDJSON files, so we should duplicate them
const isClassicCollection = originalGallery.galleryOptions?.isClassicCollection === true;
console.error('[NDJSON Duplication] Classic collection check:', {
hasClassicGuid: !!originalGallery.classicGuid,
manualCollectionName: originalGallery.galleryOptions?.manualCollectionName,
startsWithGUID: originalGallery.galleryOptions?.manualCollectionName?.startsWith('GUID='),
isClassicCollectionFlag: originalGallery.galleryOptions?.isClassicCollection,
hasPageId: !!originalGallery.pageId,
isClassicCollection: isClassicCollection,
willSkip: isClassicCollection && !originalGallery.pageId
});
// Only skip if explicitly marked as classic collection AND has no pageId
// If it has a pageId, it uses NDJSON files and we should duplicate them
if (isClassicCollection && !originalGallery.pageId) {
console.error('[NDJSON Duplication] SKIP: Gallery is explicitly marked as classic collection with no pageId, skipping NDJSON duplication');
return true; // Not an error, just not applicable
}
// Check if original gallery has a pageId
if (!originalGallery.pageId) {
console.error('[NDJSON Duplication] SKIP: Original gallery has no pageId, skipping NDJSON duplication');
console.error('[NDJSON Duplication] Original gallery keys:', Object.keys(originalGallery));
return true; // Not an error, just not applicable
}
// Get siteId
const siteId = window.siteId || originalGallery.siteId;
console.error('[NDJSON Duplication] SiteId resolution:', {
windowSiteId: window.siteId,
gallerySiteId: originalGallery.siteId,
resolvedSiteId: siteId
});
if (!siteId) {
console.error('[NDJSON Duplication] ERROR: Cannot duplicate NDJSON - missing siteId');
console.error('[NDJSON Duplication] window.siteId:', window.siteId);
console.error('[NDJSON Duplication] originalGallery.siteId:', originalGallery.siteId);
return false;
}
// Load original NDJSON
console.error('[NDJSON Duplication] Loading original NDJSON for duplication...');
console.error('[NDJSON Duplication] Using siteId:', siteId, 'pageId:', originalGallery.pageId);
const originalNdjson = await loadGalleryNDJSON(siteId, originalGallery.pageId);
if (!originalNdjson) {
console.error('[NDJSON Duplication] ERROR: Could not load original NDJSON, gallery may not have been published yet');
console.error('[NDJSON Duplication] This means the original gallery\'s NDJSON file was not found');
return false;
}
// Publish the duplicated NDJSON with new pageId using the same endpoint as edition publisher
console.error('[NDJSON Duplication] Publishing duplicated NDJSON with new pageId:', newGallery.pageId);
const dataFileName = `${siteId}_${newGallery.pageId}.ndjson`;
console.error('[NDJSON Duplication] New filename:', dataFileName);
console.error('[NDJSON Duplication] NDJSON content length:', originalNdjson.length);
try {
// Hydra sites require Bearer auth (same token resolution as Edition Publisher)
const publishHeaders = { 'Content-Type': 'application/json' };
let authToken = null;
try {
if (window.TokenManager) {
if (typeof window.TokenManager.getHydraAuthToken === 'function') {
try { authToken = window.TokenManager.getHydraAuthToken(); } catch (e) {}
}
if (!authToken && typeof window.TokenManager.getBestToken === 'function') {
try {
const tokenWithBearer = window.TokenManager.getBestToken();
authToken = tokenWithBearer && tokenWithBearer.startsWith('Bearer ')
? tokenWithBearer.substring(7)
: tokenWithBearer;
} catch (e) {}
}
}
if (!authToken) authToken = localStorage.getItem('hydra_auth_token');
if (!authToken) authToken = window.authToken || window.didToken;
if (authToken) {
publishHeaders['Authorization'] = authToken.includes('.') || authToken.startsWith('hydra:')
? 'Bearer ' + authToken
: (authToken.startsWith('Bearer ') ? authToken : 'Bearer hydra:' + authToken);
console.error('[NDJSON Duplication] Auth token attached for publish');
} else {
console.error('[NDJSON Duplication] WARNING: No auth token — publish will likely fail with 401');
}
} catch (authErr) {
console.error('[NDJSON Duplication] Auth token lookup failed:', authErr.message);
}
// Call the publish endpoint (same as edition publisher)
console.error('[NDJSON Duplication] Calling publish endpoint: https://hydra-press-v2.fly.dev/publish');
const response = await fetch('https://hydra-press-v2.fly.dev/publish', {
method: 'POST',
headers: publishHeaders,
body: JSON.stringify({
ndjsonContent: originalNdjson,
dataFileName: dataFileName
}),
});
console.error('[NDJSON Duplication] Publish response status:', response.status, response.statusText);
console.error('[NDJSON Duplication] Response headers:', Object.fromEntries(response.headers.entries()));
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
console.error('[NDJSON Duplication] ERROR: Failed to publish duplicated NDJSON:', response.status, errorText);
console.error('[NDJSON Duplication] Response status:', response.status);
console.error('[NDJSON Duplication] Response statusText:', response.statusText);
return false;
}
const result = await response.json();
console.error('[NDJSON Duplication] SUCCESS: Published duplicated NDJSON');
console.error('[NDJSON Duplication] Publish result:', JSON.stringify(result, null, 2));
console.error('[NDJSON Duplication] Publish result URLs:', result.urls);
// Note: The publish endpoint doesn't return Irys txId, so we don't update galleryOptions
// with txId/permaURL here. If Irys upload is needed, it would need to be done separately.
console.error('[NDJSON Duplication] ===== duplicateGalleryNDJSON COMPLETED SUCCESSFULLY =====');
return true;
} catch (error) {
console.error('[NDJSON Duplication] ERROR: Exception publishing duplicated NDJSON:', error);
console.error('[NDJSON Duplication] Error message:', error.message);
console.error('[NDJSON Duplication] Error stack:', error.stack);
return false;
}
} catch (error) {
console.error('[NDJSON Duplication] ERROR: Unexpected error in duplicateGalleryNDJSON:', error);
console.error('[NDJSON Duplication] Error message:', error.message);
console.error('[NDJSON Duplication] Error stack:', error.stack);
return false;
}
}
/**
* Creates and shows a loading overlay for gallery deletion (same pattern as duplication).
* @returns {Object} Object with hide() and updateMessage() to control the overlay
*/
function showDeletionOverlay() {
const existingOverlay = document.getElementById('deletion-overlay');
if (existingOverlay) existingOverlay.remove();
if (!document.querySelector('#deletion-overlay-styles')) {
const style = document.createElement('style');
style.id = 'deletion-overlay-styles';
style.textContent = `
#deletion-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 99999999; display: flex; align-items: center; justify-content: center; cursor: not-allowed; pointer-events: all; }
#deletion-overlay .deletion-message { background: rgba(255,255,255,0.95); padding: 30px 50px; font-family: Arial,sans-serif; font-size: 16px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); display: flex; flex-direction: column; align-items: center; gap: 20px; }
#deletion-overlay .deletion-spinner { width: 80px; height: 80px; color: #444; }
`;
document.head.appendChild(style);
}
const overlayEl = document.createElement('div');
overlayEl.id = 'deletion-overlay';
const messageEl = document.createElement('div');
messageEl.className = 'deletion-message';
const spinnerEl = document.createElement('div');
spinnerEl.className = 'deletion-spinner';
spinnerEl.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" viewBox="0 0 24 24"><defs><filter id="svgSpinnersDeletion"><feGaussianBlur in="SourceGraphic" result="y" stdDeviation="1"/><feColorMatrix in="y" result="z" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -7"/><feBlend in="SourceGraphic" in2="z"/></filter></defs><g filter="url(#svgSpinnersDeletion)"><circle cx="5" cy="12" r="4" fill="currentColor"><animate attributeName="cx" calcMode="spline" dur="2s" keySplines=".36,.62,.43,.99;.79,0,.58,.57" repeatCount="indefinite" values="5;8;5"/></circle><circle cx="19" cy="12" r="4" fill="currentColor"><animate attributeName="cx" calcMode="spline" dur="2s" keySplines=".36,.62,.43,.99;.79,0,.58,.57" repeatCount="indefinite" values="19;16;19"/></circle><animateTransform attributeName="transform" dur="0.75s" repeatCount="indefinite" type="rotate" values="0 12 12;360 12 12"/></g></svg>`;
const textEl = document.createElement('div');
textEl.textContent = 'Deleting...';
textEl.style.textAlign = 'center';
messageEl.appendChild(spinnerEl);
messageEl.appendChild(textEl);
overlayEl.appendChild(messageEl);
document.body.appendChild(overlayEl);
return {
hide: function() { if (overlayEl && overlayEl.parentNode) overlayEl.parentNode.removeChild(overlayEl); },
updateMessage: function(msg) { textEl.textContent = msg; }
};
}
/**
* Creates and shows a loading overlay for gallery duplication
* @returns {Object} Object with hide function to remove the overlay
*/
function showDuplicationOverlay() {
// Remove any existing overlay first
const existingOverlay = document.getElementById('duplication-overlay');
if (existingOverlay) {
existingOverlay.remove();
}
// Add styles if not already present
if (!document.querySelector('#duplication-overlay-styles')) {
const style = document.createElement('style');
style.id = 'duplication-overlay-styles';
style.textContent = `
#duplication-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 99999999;
display: flex;
align-items: center;
justify-content: center;
cursor: not-allowed;
pointer-events: all;
}
#duplication-overlay .duplication-message {
background: rgba(255, 255, 255, 0.95);
padding: 30px 50px;
font-family: Arial, sans-serif;
font-size: 16px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
#duplication-overlay .duplication-spinner {
width: 80px;
height: 80px;
color: #444444;
}
`;
document.head.appendChild(style);
}
const overlay = document.createElement('div');
overlay.id = 'duplication-overlay';
const message = document.createElement('div');
message.className = 'duplication-message';
const spinner = document.createElement('div');
spinner.className = 'duplication-spinner';
spinner.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" viewBox="0 0 24 24">
<defs>
<filter id="svgSpinnersGooeyBalls20">
<feGaussianBlur in="SourceGraphic" result="y" stdDeviation="1"/>
<feColorMatrix in="y" result="z" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -7"/>
<feBlend in="SourceGraphic" in2="z"/>
</filter>
</defs>
<g filter="url(#svgSpinnersGooeyBalls20)">
<circle cx="5" cy="12" r="4" fill="currentColor">
<animate attributeName="cx" calcMode="spline" dur="2s" keySplines=".36,.62,.43,.99;.79,0,.58,.57" repeatCount="indefinite" values="5;8;5"/>
</circle>
<circle cx="19" cy="12" r="4" fill="currentColor">
<animate attributeName="cx" calcMode="spline" dur="2s" keySplines=".36,.62,.43,.99;.79,0,.58,.57" repeatCount="indefinite" values="19;16;19"/>
</circle>
<animateTransform attributeName="transform" dur="0.75s" repeatCount="indefinite" type="rotate" values="0 12 12;360 12 12"/>
</g>
</svg>
`;
const text = document.createElement('div');
text.textContent = 'Duplicating gallery...';
text.style.textAlign = 'center';
message.appendChild(spinner);
message.appendChild(text);
overlay.appendChild(message);
document.body.appendChild(overlay);
return {
hide: function() {
if (overlay && overlay.parentNode) {
overlay.parentNode.removeChild(overlay);
}
},
updateMessage: function(newMessage) {
text.textContent = newMessage;
}
};
}
/**
* Duplicates a gallery or page item.
* @param {number} id - The ID of the item to duplicate.
* @param {Event} event - The click event.
*/
async function duplicateGalleryItem(id, event) {
event.stopPropagation();
console.log(`Duplicating item with ID: ${id}`);
// Show loading overlay
const overlay = showDuplicationOverlay();
// Find the original item in the global galleries array
const originalItem = findGalleryById(galleries, id); // Use helper if available, otherwise simple find
if (!originalItem) {
console.error(`Cannot duplicate: Item with ID ${id} not found.`);
overlay.hide();
alert('Error: Could not find the item to duplicate.');
return;
}
if (isPageMenuItem(originalItem)) {
overlay.updateMessage('Duplicating page...');
}
try {
// --- Create a Deep Copy ---
// Using JSON.parse/stringify is a common way for simple object structures
// Be cautious if your objects have Dates, functions, Maps, Sets, etc.
const newItem = JSON.parse(JSON.stringify(originalItem));
// --- Modify Copied Item ---
newItem.id = Date.now() + Math.floor(Math.random() * 1000); // Generate a new unique ID
newItem.title = `${originalItem.title}-copy`; // Append "-copy" to title
// Generate new pageId if it's a page or integrated gallery
if (newItem.isPage || newItem.isIntegrated) {
newItem.pageId = generatePageId(); // Assign a new unique pageId
}
// Generate new slug and URL
const newBaseSlug = slugify(newItem.title); // Use the new title
newItem.slug = ensureUniqueSlug(newBaseSlug, galleries); // Ensure uniqueness
newItem.url = `/${newItem.slug}`; // Update URL based on new slug
// Reset children (don't duplicate children for simplicity)
newItem.children = [];
// Deep clone galleryOptions and pageElements, update pageId if needed
if (newItem.galleryOptions) {
newItem.galleryOptions = JSON.parse(JSON.stringify(newItem.galleryOptions));
// Update pageId within options if it exists and matches the old one
if (newItem.galleryOptions.pageId && newItem.galleryOptions.pageId === originalItem.pageId) {
newItem.galleryOptions.pageId = newItem.pageId;
}
// Clear permanent-storage pointers so duplicate loads its own Tigris NDJSON, not the original
['txId', 'permaURL', 'loadTxId', 'loadPermaURL', 'loadNetworkUrl', 'r2StorageUrl',
'cdnNdjsonId', 'cdnNdjsonUrl', 'cdnNdjsonStableUrl', 'embedPointerUrl', 'cdnSaveTimestamp',
'flyIoSaveTimestamp', 'loadNetworkSaveTimestamp', 'irysSaveTimestamp', 'timestampsMatch', 'isPerma']
.forEach(function(field) { delete newItem.galleryOptions[field]; });
}
if (newItem.pageElements) {
newItem.pageElements = JSON.parse(JSON.stringify(newItem.pageElements));
// Give new IDs to duplicated page elements
newItem.pageElements.forEach(el => {
el.id = Date.now() + Math.floor(Math.random() * 10000);
});
}
// Reset home page status
newItem.isHomePage = false;
// --- Duplicate NDJSON if applicable (galleries only; pages copy content via pageElements) ---
const isDuplicatingPage = isPageMenuItem(originalItem);
console.error('[Gallery Duplication] Checking if NDJSON duplication is needed...', {
isDuplicatingPage,
newItemPageId: newItem.pageId,
originalItemPageId: originalItem.pageId
});
if (!isDuplicatingPage && newItem.pageId && originalItem.pageId) {
overlay.updateMessage('Duplicating gallery files...');
console.error('[Gallery Duplication] Both galleries have pageIds, attempting to duplicate NDJSON file...');
const ndjsonDuplicated = await duplicateGalleryNDJSON(originalItem, newItem);
if (!ndjsonDuplicated) {
console.error('[Gallery Duplication] WARNING: NDJSON duplication failed, but continuing with gallery duplication');
console.error('[Gallery Duplication] User can republish the gallery later if needed');
alert('Gallery menu item was created, but gallery images could not be copied. Open Edition Publisher on this gallery and publish to load images.');
// Don't block the duplication if NDJSON fails - user can republish later
} else {
console.error('[Gallery Duplication] SUCCESS: NDJSON duplication completed successfully');
}
} else {
console.error('[Gallery Duplication] SKIP: Skipping NDJSON duplication:', {
isDuplicatingPage,
hasNewPageId: !!newItem.pageId,
hasOriginalPageId: !!originalItem.pageId,
reason: isDuplicatingPage ? 'Page items use pageElements, not NDJSON' :
!newItem.pageId ? 'New gallery has no pageId' : 'Original gallery has no pageId'
});
}
overlay.updateMessage(isDuplicatingPage ? 'Saving duplicated page...' : 'Saving duplicated gallery...');
// --- Insert into Galleries Array ---
// Find the index of the original item
const originalIndex = galleries.findIndex(item => item.id === id);
if (originalIndex > -1) {
// Insert the new item right after the original
galleries.splice(originalIndex + 1, 0, newItem);
console.log(`Inserted duplicate "${newItem.title}" after "${originalItem.title}"`);
// Update positions for all items after the insertion point
for (let i = originalIndex + 1; i < galleries.length; i++) {
galleries[i].position = i; // Re-assign position based on new index
}
} else {
// Fallback: Add to the end if original couldn't be found (shouldn't happen)
console.warn(`Original item ${id} not found in array, adding duplicate to the end.`);
newItem.position = galleries.length;
galleries.push(newItem);
}
console.log('Rendering galleries immediately after duplication...');
renderGalleries();
// Update active states if necessary (though unlikely needed here)
updateActiveStates();
// --- Save and Refresh ---
console.log('Saving duplicated item...');
saveGalleries(null, { addedNewItem: true }) // Pass flag to trigger refresh
.then(success => {
overlay.hide(); // Hide overlay before page refresh
if (success) {
console.log('Duplicate saved successfully. Page will refresh.');
// No need to call renderGalleries() here because saveGalleries will trigger a reload.
} else {
console.error('Failed to save the duplicated item.');
alert('Error: Could not save the duplicated item.');
// Optionally try to revert the galleries array change here if save fails
}
})
.catch(error => {
overlay.hide(); // Hide overlay on error
console.error('Error saving duplicated item:', error);
alert('Error saving duplicated item.');
});
} catch (error) {
overlay.hide(); // Hide overlay on error
console.error('Error duplicating item:', error);
alert('An error occurred while duplicating the item.');
}
}
/**
* saveGalleries function that always saves FULL data
* Ensures gallery settings are preserved in both localStorage and server
*
* @param {Object} customData - Optional custom data to save instead of galleries
* @returns {Promise<boolean>} - Success indicator
*/
async function saveGalleries(customData) {
if (!isAuthenticated && localStorage.getItem('hydra_is_admin') !== 'true') {
alert('You must be logged in as an admin to save changes');
return false;
}
if (window.HelpSystem && typeof window.HelpSystem.isSandboxActive === 'function' && window.HelpSystem.isSandboxActive()) {
console.warn('Save blocked: help sandbox is active');
return false;
}
function _hasHelpDemoInTree(items) {
if (!Array.isArray(items)) return false;
for (var i = 0; i < items.length; i++) {
var g = items[i];
if (!g) continue;
if (g.isHelpDemo || Number(g.id) < 0) return true;
if (g.children && _hasHelpDemoInTree(g.children)) return true;
}
return false;
}
if (window.activeGalleryId != null && Number(window.activeGalleryId) < 0) {
console.warn('Save blocked: active help demo gallery');
return false;
}
var savePayload = customData && customData.galleries ? customData.galleries : galleries;
if (_hasHelpDemoInTree(savePayload)) {
console.warn('Save blocked: payload contains help demo records');
return false;
}
// Prevent multiple simultaneous saves — queue the latest payload instead of dropping it
if (window._saveInProgress) {
console.log('Save already in progress, queueing latest payload');
if (customData) {
window._pendingSaveGalleriesData = customData;
}
return new Promise(function(resolve) {
window._pendingSaveResolvers = window._pendingSaveResolvers || [];
window._pendingSaveResolvers.push(resolve);
});
}
window._saveInProgress = true;
try {
// CRITICAL: Synchronize galleries before saving to ensure we use the latest data
if (typeof synchronizeGalleries === 'function') {
synchronizeGalleries();
console.log('Galleries synchronized before saving');
}
// Try to get a valid token from multiple sources
let token = null;
let tokenSource = 'none';
// First try the global didToken (traditional approach)
if (didToken) {
console.log('Using global didToken for save operation');
token = didToken;
tokenSource = 'didToken';
}
// Next try TokenManager if available
else if (window.TokenManager && typeof window.TokenManager.getToken === 'function') {
token = window.TokenManager.getToken();
// Remove Bearer prefix if present (we'll add it later)
if (token && token.startsWith('Bearer ')) {
token = token.substring(7);
}
console.log('Using TokenManager token for save operation, length:', token ? token.length : 0);
tokenSource = 'TokenManager';
}
// Fallback to localStorage
else if (localStorage.getItem('hydra_auth_token')) {
token = localStorage.getItem('hydra_auth_token');
console.log('Using localStorage token for save operation, length:', token.length);
tokenSource = 'localStorage';
}
// Last resort: try to get a fresh token
else if (magic && magic.user) {
try {
console.log('Attempting to get fresh token from Magic');
token = await magic.user.getIdToken();
console.log('Obtained fresh token from Magic:', token ? 'success' : 'failed');
tokenSource = token ? 'magic' : 'none';
} catch (e) {
console.error('Error getting token from Magic:', e);
tokenSource = 'magic_error';
}
}
// [AUTH_SAVE] Log for S+J download - token state and logged-in email before request
const loggedInEmail = (window.userMetadata && window.userMetadata.email) ||
localStorage.getItem('hydra_auth_email') ||
'';
console.log('[AUTH_SAVE] Token resolved:', {
loggedInEmail: loggedInEmail || '(none)',
tokenSource,
tokenLength: token ? token.length : 0,
tokenPrefix: token ? token.substring(0, 15) + '...' : '(none)',
hasToken: !!token,
isAuthenticated,
hydraIsAdmin: localStorage.getItem('hydra_is_admin') === 'true',
hasLocalStorageToken: !!localStorage.getItem('hydra_auth_token'),
hasTokenManager: !!(window.TokenManager && typeof window.TokenManager.getToken === 'function')
});
if (!token) {
console.error('[AUTH_SAVE] No token available - save will fail. Sources checked: didToken, TokenManager, localStorage, magic.');
throw new Error('Could not find a valid authentication token. Please log in again.');
}
// IMPORTANT: Try to save to localStorage with isolated error handling
// This ensures localStorage failures don't prevent server saves
if (!customData) { // Only try localStorage for regular gallery saves
try {
// FIXED: Always save the FULL galleries data to localStorage
localStorage.setItem('galleries', JSON.stringify(galleries));
console.log(`Saved ${galleries.length} galleries to localStorage (FULL format with complete settings)`);
} catch (storageError) {
// Now storage errors are isolated and non-fatal
console.warn('LocalStorage quota exceeded, proceeding without local backup', storageError);
}
} else if (customData && customData.isImport) {
// Skip localStorage for import operations - data too large
console.log('Skipping localStorage for import operation - data too large for local storage');
}
// IMPORTANT: Ensure all pages have proper URLs and slugs before saving
galleries.forEach(gallery => {
if (gallery.isPage === true) {
const pageSlug = slugify(gallery.title);
// Make sure slug and URL are set correctly
if (!gallery.slug || gallery.slug === '') {
gallery.slug = pageSlug;
}
// Make sure URL points to slug
if (!gallery.url || gallery.url === '/' || !gallery.url.includes(gallery.slug)) {
gallery.url = `/${pageSlug}`;
}
}
});
// Set up the data to save - ensure we're using the complete data
let siteData;
console.log('saveGalleries: customData received:', JSON.stringify(customData));
console.log('saveGalleries: siteMetadata in customData:', customData?.siteMetadata);
console.log('saveGalleries: TITLE in customData:', customData?.siteMetadata?.title);
console.log('saveGalleries: GOOGLE ANALYTICS in customData:', customData?.siteMetadata?.googleAnalytics);
if (customData) {
// CRITICAL FIX: Ensure gallery settings are preserved when saving from sidebar
// If customData contains galleries (which it should for sidebar-initiated saves),
// merge them with any existing settings to ensure nothing is lost
if (customData.galleries && Array.isArray(customData.galleries)) {
// First, ensure we preserve all current gallery settings from the existing galleries
const existingGalleries = (typeof galleries !== 'undefined' ? galleries : window.galleries) || [];
// Create merged galleries array with preserved settings
const mergedGalleries = customData.galleries.map(gallery => {
const existingGallery = existingGalleries.find(g => g.id === gallery.id);
// If this gallery exists in the current dataset, ensure all galleryOptions are preserved
if (existingGallery && existingGallery.galleryOptions && gallery.galleryOptions) {
// Calculate settings depth
const existingOptionsDepth = Object.keys(existingGallery.galleryOptions).length;
const serverOptionsDepth = Object.keys(gallery.galleryOptions).length;
// If server has fewer settings, merge them with existing settings
if (serverOptionsDepth < existingOptionsDepth && existingOptionsDepth > 5) {
console.log(`Preserving rich settings for ${gallery.title} (${existingOptionsDepth} vs ${serverOptionsDepth})`);
// Create a merged gallery with preserved settings
return {
...gallery,
galleryOptions: {
...existingGallery.galleryOptions, // Start with all existing settings
...gallery.galleryOptions // Override with any new server settings
}
};
}
}
// If no preservation needed, use server gallery as is
return gallery;
});
// Use the merged galleries in customData
siteData = {
...customData,
galleries: mergedGalleries
};
console.log(`Settings-preservation merge completed: ${mergedGalleries.length} galleries`);
console.log('saveGalleries: siteData after merge:', JSON.stringify(siteData));
console.log('saveGalleries: siteMetadata after merge:', siteData.siteMetadata);
console.log('saveGalleries: TITLE after merge:', siteData.siteMetadata?.title);
console.log('saveGalleries: GOOGLE ANALYTICS after merge:', siteData.siteMetadata?.googleAnalytics);
} else {
siteData = customData;
}
} else {
const galleriesForSave = (typeof window !== 'undefined' && window.galleries && Array.isArray(window.galleries))
? window.galleries
: galleries;
siteData = {
galleries: galleriesForSave
};
if (window.siteMetadata) {
siteData.siteMetadata = window.siteMetadata;
}
if (window.SidebarManager) {
siteData.sidebarElements = window.SidebarManager.elements;
}
}
// Ensure galleries are never saved as pages: any item with galleryOptions is a gallery and must persist with isPage false.
// (loadPage can set isPage=true in memory; sidebar/full saves would otherwise persist that and convert TEAM etc. to pages.)
if (siteData.galleries && Array.isArray(siteData.galleries)) {
siteData.galleries = siteData.galleries.map(g => {
if (g && g.galleryOptions && typeof g.galleryOptions === 'object') {
return { ...g, isPage: false };
}
return g;
});
}
// Get the save URL
const saveUrl = typeof getApiUrl === 'function' ?
getApiUrl('/api/save-config') :
'/api/save-config';
// Get user email from various sources
const userEmail = (window.userMetadata && window.userMetadata.email) ||
localStorage.getItem('hydra_auth_email') ||
'';
// Ensure token has proper format
const formattedToken = token.startsWith('hydra:') ? token :
(token.includes('.') ? token : `hydra:${token}`);
console.log('Making server save request...');
console.log('saveGalleries: Final siteData being sent to server:', JSON.stringify(siteData));
console.log('saveGalleries: Final siteMetadata being sent:', siteData.siteMetadata);
console.log('saveGalleries: FINAL TITLE being sent:', siteData.siteMetadata?.title);
console.log('saveGalleries: FINAL GOOGLE ANALYTICS being sent:', siteData.siteMetadata?.googleAnalytics);
// [AUTH_SAVE] Log for S+J download - request details (no full token)
console.log('[AUTH_SAVE] Sending save request:', {
saveUrl,
authHeaderPrefix: `Bearer ${formattedToken.substring(0, 20)}...`,
userEmail: userEmail || '(empty)',
tokenSource,
formattedTokenLength: formattedToken.length
});
// Make the request
let response = await fetch(saveUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${formattedToken}`,
'X-User-Email': userEmail,
'X-API-Request': 'true' // Always include this header for API requests
},
body: JSON.stringify(siteData)
});
console.log('Save response status:', response.status);
// Process the response
let responseText = await response.text();
console.log('Server response text (first 500 chars):', responseText.substring(0, 500));
let responseData;
try {
responseData = JSON.parse(responseText);
console.log('Server response parsed successfully:', responseData);
if (responseData.debug) {
console.log('Server debug info:', responseData.debug);
console.log('Server debug - requestBodyKeys:', responseData.debug.requestBodyKeys);
console.log('Server debug - hasSiteMetadata:', responseData.debug.hasSiteMetadata);
console.log('Server debug - siteMetadata:', responseData.debug.siteMetadata);
console.log('Server debug - finalSiteMetadata:', responseData.debug.finalSiteMetadata);
console.log('Server debug - googleAnalytics:', responseData.debug.googleAnalytics);
}
} catch (jsonError) {
if (response.status === 200 && responseText.trim().startsWith('<!DOCTYPE html>')) {
responseData = { success: true };
} else {
throw new Error('Invalid response format: ' + responseText.substring(0, 100));
}
}
if (!response.ok && !responseData.success) {
// [AUTH_SAVE] Log for S+J download - capture full 401/auth failure details
const authSaveFailure = {
status: response.status,
statusText: response.statusText,
error: responseData && responseData.error,
details: responseData && responseData.details,
authMethod: responseData && responseData.authMethod,
fullResponse: responseData,
responseTextPreview: responseText.substring(0, 300)
};
console.error('[AUTH_SAVE] Save request failed:', authSaveFailure);
// Retry once on 401: token may have expired - try refresh or fresh Magic token
if (response.status === 401) {
console.log('[AUTH_SAVE] 401 received - attempting token refresh before retry...');
let newToken = null;
if (window.TokenManager && typeof window.TokenManager.refreshToken === 'function') {
newToken = await window.TokenManager.refreshToken();
}
if (!newToken && magic && magic.user) {
try {
newToken = await magic.user.getIdToken();
} catch (e) {
console.warn('[AUTH_SAVE] Magic getIdToken failed:', e);
}
}
if (newToken) {
if (newToken.startsWith('Bearer ')) newToken = newToken.substring(7);
token = newToken;
didToken = newToken;
localStorage.setItem('hydra_auth_token', newToken);
if (window.TokenManager && typeof window.TokenManager.storeToken === 'function') {
window.TokenManager.storeToken(newToken.startsWith('hydra:') ? newToken : `hydra:${newToken}`, userEmail);
}
console.log('[AUTH_SAVE] Got fresh token, retrying save...');
const retryResponse = await fetch(saveUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token.startsWith('hydra:') ? token : (token.includes('.') ? token : `hydra:${token}`)}`,
'X-User-Email': userEmail,
'X-API-Request': 'true'
},
body: JSON.stringify(siteData)
});
const retryText = await retryResponse.text();
let retryData;
try { retryData = JSON.parse(retryText); } catch (e) {
if (retryResponse.status === 200 && retryText.startsWith('<!DOCTYPE')) retryData = { success: true };
else throw new Error('Invalid retry response');
}
if (retryResponse.ok || retryData.success) {
response = retryResponse;
responseText = retryText;
responseData = retryData;
console.log('[AUTH_SAVE] Retry succeeded');
} else {
console.error('[AUTH_SAVE] Retry also failed:', retryResponse.status, retryData);
throw new Error(`Failed to save: ${response.status} - ${JSON.stringify(responseData)}`);
}
} else {
throw new Error(`Failed to save: ${response.status} - ${JSON.stringify(responseData)}`);
}
} else {
throw new Error(`Failed to save: ${response.status} - ${JSON.stringify(responseData)}`);
}
}
console.log('Configuration saved successfully');
// Update local lastKnownConfigTimestamp after successful save
if (responseData.lastModified) {
window.lastKnownConfigTimestamp = responseData.lastModified;
console.error('[CONFLICT DETECT] ✅ Updated local config timestamp from save response:', window.lastKnownConfigTimestamp);
} else {
console.error('[CONFLICT DETECT] ⚠️ No lastModified in save response, fetching from server...');
// If server doesn't return timestamp, fetch it
try {
const timestampUrl = typeof getApiUrl === 'function' ?
getApiUrl('/api/config-timestamp') :
'/api/config-timestamp';
const timestampResponse = await fetch(timestampUrl);
if (timestampResponse.ok) {
const timestampData = await timestampResponse.json();
if (timestampData.lastModified) {
window.lastKnownConfigTimestamp = timestampData.lastModified;
console.error('[CONFLICT DETECT] ✅ Fetched and updated local config timestamp:', window.lastKnownConfigTimestamp);
} else {
console.error('[CONFLICT DETECT] ⚠️ No lastModified in fetched timestamp data:', timestampData);
}
} else {
console.error('[CONFLICT DETECT] ⚠️ Failed to fetch timestamp after save, status:', timestampResponse.status);
}
} catch (err) {
console.error('[CONFLICT DETECT] ⚠️ Could not fetch timestamp after save:', err);
}
}
// Store new token if provided
if (responseData.hydraToken) {
console.log('Received new hydra token from server');
// Update the global didToken
didToken = responseData.hydraToken.startsWith('hydra:') ?
responseData.hydraToken.substring(6) : responseData.hydraToken;
// Update localStorage
localStorage.setItem('hydra_auth_token', didToken);
// Update TokenManager if available
if (window.TokenManager && typeof window.TokenManager.storeToken === 'function') {
window.TokenManager.storeToken(responseData.hydraToken, userEmail);
}
}
// Process server response data if needed
if (responseData.galleries) {
// If server returns updated galleries data, update the global galleries variable
if (Array.isArray(responseData.galleries) && responseData.galleries.length > 0) {
// CRITICAL FIX: Before replacing galleries with server response,
// preserve any gallery settings that might be lost in the response
const updatedGalleries = responseData.galleries.map(serverGallery => {
// Find the matching gallery in our current data
const existingGallery = galleries.find(g => g.id === serverGallery.id);
// Check if we need to preserve gallery settings
if (existingGallery && existingGallery.galleryOptions && serverGallery.galleryOptions) {
// Calculate settings depth
const existingOptionsDepth = Object.keys(existingGallery.galleryOptions).length;
const serverOptionsDepth = Object.keys(serverGallery.galleryOptions).length;
// If server has fewer settings, merge them with existing settings
if (serverOptionsDepth < existingOptionsDepth && existingOptionsDepth > 5) {
console.log(`Preserving rich settings for ${serverGallery.title} (${existingOptionsDepth} vs ${serverOptionsDepth})`);
// Create a merged gallery with preserved settings
return {
...serverGallery,
galleryOptions: {
...existingGallery.galleryOptions, // Start with all existing settings
...serverGallery.galleryOptions // Override with any new server settings
}
};
}
}
// If no preservation needed, use server gallery as is
return serverGallery;
});
// Update the galleries variable with our preserved data
galleries = updatedGalleries;
console.log(`Updated galleries from server response: ${galleries.length} galleries`);
// IMPORTANT: Synchronize again after receiving server response
if (typeof synchronizeGalleries === 'function') {
synchronizeGalleries();
console.log('Galleries re-synchronized after server response');
}
// Keep sidebar UI in sync with current galleries (e.g. after delete)
if (typeof renderGalleries === 'function') {
renderGalleries();
}
// Update localStorage with full data
try {
localStorage.setItem('galleries', JSON.stringify(galleries));
console.log('Updated localStorage with server gallery data (FULL format)');
} catch (e) {
console.warn('Could not update localStorage with server gallery data', e);
}
}
}
// CRITICAL FIX: Clear all sessionStorage caches after successful save
// This ensures that when users open the site in a new tab, they get fresh data
try {
console.log('Clearing sessionStorage caches after successful save...');
const keysToRemove = [];
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i);
if (key && (key.includes('gallery_data_') || key.includes('neonGallery_'))) {
keysToRemove.push(key);
}
}
keysToRemove.forEach(key => {
sessionStorage.removeItem(key);
console.log('Cleared sessionStorage key: ' + key);
});
console.log('Cleared ' + keysToRemove.length + ' sessionStorage cache entries');
} catch (cacheError) {
console.warn('Error clearing sessionStorage cache:', cacheError);
// Don't fail the save if cache clearing fails
}
window._saveInProgress = false;
if (!customData || !Array.isArray(customData.sidebarElements)) {
document.dispatchEvent(new CustomEvent('sidebar-save-requested'));
}
// Flush any save queued while this request was in flight
if (window._pendingSaveGalleriesData) {
const pending = window._pendingSaveGalleriesData;
window._pendingSaveGalleriesData = null;
const resolvers = window._pendingSaveResolvers || [];
window._pendingSaveResolvers = [];
return saveGalleries(pending).then(function(result) {
resolvers.forEach(function(r) { r(result); });
return result;
});
}
const idleResolvers = window._pendingSaveResolvers || [];
window._pendingSaveResolvers = [];
idleResolvers.forEach(function(r) { r(true); });
return true;
} catch (error) {
// [AUTH_SAVE] Log for S+J download - capture exception (network, parse, or thrown after 401)
console.error('[AUTH_SAVE] Save threw exception:', {
message: error.message,
name: error.name,
stack: error.stack
});
console.error('Error saving configuration:', error);
alert('Failed to save changes: ' + error.message);
window._saveInProgress = false;
const failResolvers = window._pendingSaveResolvers || [];
window._pendingSaveResolvers = [];
failResolvers.forEach(function(r) { r(false); });
return false;
}
}
// Helper function to get API URL for preview sites
function getApiUrl(endpoint) {
// Check if we're on a preview URL
const isPreviewUrl = window.location.hostname === 'preview.neonsky.app';
if (isPreviewUrl) {
// Extract the site GUID from the path (first segment after domain)
const pathParts = window.location.pathname.split('/').filter(Boolean);
if (pathParts.length > 0) {
const siteGuid = pathParts[0];
// Include the site GUID in the API URL
return `/${siteGuid}${endpoint}`;
}
}
// Regular case - return the endpoint as is
return endpoint;
}
function updateActiveStates() {
// Get the most current active gallery ID, ensuring consistency between both variables
const currentActiveId = window.activeGalleryId || activeGalleryId;
// Ensure both variables are synchronized
window.activeGalleryId = currentActiveId;
activeGalleryId = currentActiveId;
console.log("updateActiveStates: Using active gallery ID:", currentActiveId);
// Special case for invisible home page - don't try to highlight it in the menu
const activeGallery = galleries.find(g => g.id === currentActiveId);
if (activeGallery && activeGallery.visible === false && activeGallery.isHomePage === true) {
console.log("Active gallery is invisible home page - not highlighting in menu");
// We're not returning here, so it will still update the visibility classes
// but won't try to find an element to mark as active
}
// Fix selector issue - look for both .tree and .sidebar
document.querySelectorAll('.tree li, .sidebar li').forEach(li => {
if (!li.dataset.id) return; // Skip items without data-id attribute
const id = parseInt(li.dataset.id);
if (isNaN(id)) return; // Skip if ID is not a number
const gallery = galleries.find(g => g.id === id);
// Update active state using the most current ID
const isActive = id === currentActiveId;
// Add or remove the active class
if (isActive) {
li.classList.add('active');
console.log(`Setting active class for element: ${id} (${gallery ? gallery.title : 'Unknown'})`);
} else {
li.classList.remove('active');
}
// Update visibility class if gallery exists
if (gallery) {
li.classList.toggle('hidden-gallery', gallery.visible === false);
// Update visibility toggle icon if it exists
const toggleButton = li.querySelector('.visibility-toggle');
if (toggleButton) {
toggleButton.classList.toggle('hidden', gallery.visible === false);
}
}
});
// Verify that the active state was actually set for the current activeGalleryId
const activeElements = document.querySelectorAll('.active');
if (activeElements.length === 0 && currentActiveId) {
// Only log a warning if we're not dealing with an invisible home page
const activeGallery = galleries.find(g => g.id === currentActiveId);
if (!(activeGallery && activeGallery.visible === false && activeGallery.isHomePage === true)) {
console.warn(`No active elements found after update for ID: ${currentActiveId}`);
}
}
// Log active items for debugging
console.log("Active gallery ID after update:", currentActiveId);
activeElements.forEach(el =>
console.log("Active element:", el.textContent.trim())
);
}
function debugGalleryStructure() {
console.group('Gallery Structure Debug');
// Log the raw data structure
console.log('Raw galleries array:', JSON.parse(JSON.stringify(galleries)));
// Log the tree structure
const treeStructure = createGalleryTree(galleries);
// Helper to print the tree
function printTree(items, level = 0) {
const indent = ' '.repeat(level);
items.forEach(item => {
console.log(`${indent}${item.title} (ID: ${item.id}, Parent: ${item.parentId || 'ROOT'})`);
if (item.children && item.children.length > 0) {
printTree(item.children, level + 1);
}
});
}
console.log('Tree structure:');
printTree(treeStructure);
// Log the DOM structure
console.log('DOM structure:');
const treeEl = document.getElementById('galleryTree');
console.log(treeEl);
console.groupEnd();
}
function createGalleryTree(galleries) {
console.log("Creating gallery tree with preservation safeguards...");
// First, create a map of galleries by id for quick lookup
const galleryMap = {};
const processedIds = new Set();
// First pass: Create clean copies without children
galleries.forEach(gallery => {
if (!gallery || !gallery.id) return; // Skip invalid galleries
// Create a copy of the gallery object without the children field
const cleanGallery = { ...gallery };
delete cleanGallery.children;
// Initialize an empty children array
cleanGallery.children = [];
// Add to map - even if we've seen this ID before, keep the latest version
if (processedIds.has(gallery.id)) {
console.warn(`Multiple galleries with ID ${gallery.id} - using latest version`);
}
processedIds.add(gallery.id);
galleryMap[gallery.id] = cleanGallery;
});
// Second pass: Assign children to their parents
const rootGalleries = [];
// To detect already-processed children
const childrenAssigned = new Set();
galleries.forEach(gallery => {
if (!gallery || !gallery.id) return; // Skip invalid galleries
const galleryId = gallery.id;
// Skip if this gallery isn't in our map (could be invalid)
if (!galleryMap[galleryId]) return;
// Skip if we've already assigned this gallery as a child
if (childrenAssigned.has(galleryId)) return;
if (gallery.parentId && galleryMap[gallery.parentId]) {
// This is a child gallery, but first check it doesn't create a circular reference
// Find all parents in the chain up to the root
let currentParent = galleryMap[gallery.parentId];
let safeToAdd = true;
let chainIds = [galleryId, gallery.parentId];
while (currentParent.parentId) {
// If we've seen this ID in the chain, it would create a circular reference
if (chainIds.includes(currentParent.parentId)) {
console.warn(`Would create circular reference for gallery ${gallery.title} - making it a root gallery`);
safeToAdd = false;
break;
}
chainIds.push(currentParent.parentId);
currentParent = galleryMap[currentParent.parentId];
// If parent doesn't exist in the map, break the chain
if (!currentParent) break;
}
if (safeToAdd) {
// Safe to add as child
galleryMap[gallery.parentId].children.push(galleryMap[galleryId]);
childrenAssigned.add(galleryId);
} else {
// Not safe - make it a root gallery
rootGalleries.push(galleryMap[galleryId]);
}
} else {
// This is a root gallery (no parent or parent doesn't exist)
if (!childrenAssigned.has(galleryId)) {
rootGalleries.push(galleryMap[galleryId]);
}
}
});
// Final verification
const allGalleryIds = new Set(galleries.map(g => g.id));
const treeGalleryIds = new Set();
// Function to collect all IDs in the tree
const collectIds = (items) => {
items.forEach(item => {
treeGalleryIds.add(item.id);
if (item.children && item.children.length > 0) {
collectIds(item.children);
}
});
};
collectIds(rootGalleries);
// Check if any galleries are missing from the tree
const missingIds = [];
allGalleryIds.forEach(id => {
if (!treeGalleryIds.has(id)) {
missingIds.push(id);
}
});
if (missingIds.length > 0) {
console.warn(`${missingIds.length} galleries are missing from the tree structure`);
// Add the missing galleries as root items
missingIds.forEach(id => {
const gallery = galleryMap[id];
if (gallery && !childrenAssigned.has(id)) {
console.log(`Adding missing gallery to root: ${gallery.title}`);
rootGalleries.push(gallery);
}
});
}
console.log(`Created tree with ${rootGalleries.length} root galleries`);
return rootGalleries;
}
function alignFolderStates() {
console.log("Aligning folder data states with collapsed visual appearance");
let changed = 0;
galleries.forEach(gallery => {
if ((gallery.isFolder === true || gallery.isSubmenu === true) && gallery.isCollapsed === false) {
gallery.isCollapsed = true;
changed++;
}
});
}
function renderGalleryItem(gallery, level = 0, maxLevel = 10) {
// Prevent infinite recursion by limiting depth
if (level >= maxLevel) {
console.warn(`Maximum nesting level reached for gallery: ${gallery.title}`);
return '';
}
// Add debug info to console
console.log(`Rendering gallery item: ${gallery.title}, isEditing=${isEditing}, level=${level}`);
// Skip hidden items in view mode (but not spacers)
if (!isEditing && gallery.visible === false && !gallery.isSpacer) {
return '';
}
const isPage = isPageMenuItem(gallery);
const isFolder = gallery.isFolder === true || gallery.isSubmenu === true; // Support both folder and legacy submenu
const isSpacer = gallery.isSpacer === true;
const isExternal = gallery.isExternal === true;
// CRITICAL FIX: Ensure children are always populated for folders
// If gallery.children is missing or empty, but this is a folder, build children from flat galleries array
// This ensures the nested <ol> is always rendered with children in the DOM, even in view mode
if (isFolder) {
// Always ensure children array exists
if (!gallery.children) {
gallery.children = [];
}
// If children array is empty, build it from flat galleries array using parentId
if (gallery.children.length === 0) {
const childrenFromFlat = galleries.filter(g => g.parentId === gallery.id);
if (childrenFromFlat.length > 0) {
gallery.children = childrenFromFlat;
console.log(`[renderGalleryItem] Built children for folder ${gallery.title}: ${gallery.children.length} children`);
}
}
}
// Determine if item has children
const hasChildren = isFolder || gallery.children?.length > 0;
const hasChildrenClass = hasChildren ? 'has-children' : '';
// Standard collapsed state check
const isCollapsed = gallery.isCollapsed === true;
// Simple expanded class logic - if the folder is not collapsed, add 'expanded' class
const isExpandedClass = isFolder && !isCollapsed ? 'expanded' : '';
// Add additional classes for special types
const spacerClass = isSpacer ? 'spacer-item' : '';
const externalClass = isExternal ? 'external-link' : '';
const folderClass = isFolder ? 'folder-item' : '';
const duplicateIconSvg = `
<svg xmlns="[http://www.w3.org/2000/svg](http://www.w3.org/2000/svg)" width="24" height="24" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" color="currentColor">
<path d="M16.964 8.982c-.003-2.95-.047-4.478-.906-5.524a4 4 0 0 0-.553-.554C14.4 2 12.76 2 9.48 2s-4.92 0-6.024.905a4 4 0 0 0-.553.554C1.998 4.56 1.998 6.2 1.998 9.48s0 4.92.906 6.023q.25.304.553.553c1.047.86 2.575.904 5.525.906"/>
<path d="m14.028 9.025l2.966-.043m-2.98 13.02l2.966-.043m4.992-7.937l-.028 2.96M9.01 14.036l-.028 2.96m2.505-7.971c-.832.149-2.17.302-2.477 2.024m10.485 10.91c.835-.137 2.174-.27 2.508-1.986M19.495 9.025c.832.149 2.17.302 2.477 2.024M11.5 21.957c-.833-.148-2.17-.301-2.478-2.023"/>
</g>
</svg>
`;
// NEW: Add nesting level as a data attribute for styling
const nestingLevelAttr = `data-nesting-level="${level}"`;
// Accessible href for SPA items (loadPage/loadGallery still preventDefault)
const menuHref = gallery.isHomePage
? '/'
: (gallery.url && String(gallery.url).startsWith('/')
? gallery.url
: (gallery.slug ? ('/' + gallery.slug) : '#'));
const childrenListId = 'gallery-children-' + gallery.id;
const folderExpandedAttr = !isCollapsed ? 'true' : 'false';
const ariaCurrentAttr = (gallery.id === activeGalleryId && !isFolder && !isSpacer)
? ' aria-current="page"'
: '';
// Create the gallery item HTML with enhanced nesting indicators
const html = `
<li data-id="${gallery.id}"
${nestingLevelAttr}
class="${gallery.id === activeGalleryId ? 'active' : ''}
${gallery.visible === false ? 'hidden-gallery' : ''}
${hasChildrenClass} ${isExpandedClass} ${spacerClass} ${externalClass} ${folderClass}">
<div class="gallery-item-content nested-sortable-handle">
${hasChildren ? `
<button type="button" class="submenu-toggle" onclick="toggleFolder(${gallery.id}, event)" aria-expanded="${folderExpandedAttr}" aria-controls="${childrenListId}" aria-label="Expand or collapse ${String(gallery.title || 'folder').replace(/"/g, '"')}">
<svg class="icon toggle-icon ${!isCollapsed ? 'rotated' : ''}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<polyline points="9 6 15 12 9 18"></polyline>
</svg>
</button>
` : ''}
<!-- Menu item with different rendering based on type -->
${isSpacer ? `
<!-- Spacer with non-breaking space to ensure height -->
<span class="menu-item spacer" aria-hidden="true"> </span>
` : isExternal ? `
<!-- External URL uses an anchor tag with target="_blank" -->
<a href="${gallery.url}" class="menu-item external-link" target="_blank" rel="noopener noreferrer"${ariaCurrentAttr}>
${gallery.title}
</a>
` : isFolder ? `
<!-- Folder item with toggle functionality (button for keyboard / SR) -->
<button type="button" class="menu-item folder" data-folder="true"
onclick="toggleFolder(${gallery.id}, event)" aria-expanded="${folderExpandedAttr}" aria-controls="${childrenListId}">
${gallery.title}
</button>
` : `
<!-- Real link for SR/keyboard; SPA handlers keep click behavior -->
<a href="${menuHref}" class="menu-item" onclick="${isPage ? `loadPage(${gallery.id}, event)` : `loadGallery(${gallery.id}, event)`}"${ariaCurrentAttr}>
${gallery.title}
</a>
`}
<div class="controls">
${!isSpacer ? `
<button class="btn visibility-toggle ${gallery.visible === false ? 'hidden' : ''}" onclick="toggleVisibility(${gallery.id}, event)">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
${gallery.visible !== false ?
'<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle>' :
'<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path><line x1="1" y1="1" x2="23" y2="23"></line>'
}
</svg>
</button>
<button class="btn password-toggle ${gallery.passwordProtected ? 'protected' : ''}" onclick="toggleGalleryPasswordProtection(${gallery.id}, event)" title="${gallery.passwordProtected ? 'Remove Password Protection' : 'Add Password Protection'}">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24">
${gallery.passwordProtected ?
'<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" color="currentColor"><path d="M12 16.5v-2m-7.732 4.345c.225 1.67 1.608 2.979 3.292 3.056c1.416.065 2.855.099 4.44.099s3.024-.034 4.44-.1c1.684-.076 3.067-1.385 3.292-3.055c.147-1.09.268-2.207.268-3.345s-.121-2.255-.268-3.345c-.225-1.67-1.608-2.979-3.292-3.056A95 95 0 0 0 12 9c-1.585 0-3.024.034-4.44.1c-1.684.076-3.067 1.385-3.292 3.055C4.12 13.245 4 14.362 4 15.5s.121 2.255.268 3.345"/><path d="M7.5 9V6.5a4.5 4.5 0 0 1 9 0V9"/></g>' :
'<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 16.5v-2m-7.732 4.345c.225 1.67 1.608 2.979 3.292 3.056c1.416.065 2.855.099 4.44.099s3.024-.034 4.44-.1c1.684-.076 3.067-1.385 3.292-3.055c.147-1.09.268-2.207.268-3.345s-.121-2.255-.268-3.345c-.225-1.67-1.608-2.979-3.292-3.056A95 95 0 0 0 12 9c-1.585 0-3.024.034-4.44.1c-1.684.076-3.067 1.385-3.292 3.055C4.12 13.245 4 14.362 4 15.5s.121 2.255.268 3.345M7.5 9V6.5A4.5 4.5 0 0 1 12 2c1.96 0 3.5 1.5 4 3" color="currentColor"/>'
}
</svg>
</button>
<button class="btn edit-btn" onclick="editGallery(${gallery.id}, event)">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
` : ''}
<button class="btn duplicate-btn" onclick="duplicateGalleryItem(${gallery.id}, event)" title="Duplicate">
${duplicateIconSvg}
</button>
<button class="btn delete-btn" onclick="deleteGallery(${gallery.id}, event)">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
${!isSpacer ? `
<!-- Add home toggle button -->
<button class="btn home-toggle ${gallery.isHomePage ? 'active' : ''}" onclick="toggleHome(${gallery.id}, event)" title="${gallery.isHomePage ? 'Currently set as Home page' : 'Set as Home page'}">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
${gallery.isHomePage ?
'<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m3.966 5.798l.603-1.401c.501-1.163.751-1.745 1.242-2.07C6.302 2 6.927 2 8.177 2h7.646c1.25 0 1.875 0 2.366.326c.49.326.741.908 1.242 2.07l.603 1.402c.817 1.896 1.225 2.844.788 3.523S19.366 10 17.328 10H6.671c-2.037 0-3.056 0-3.493-.679s-.029-1.627.788-3.523M4.5 10v5c0 1.886 0 2.828.586 3.414c.471.472 1.174.564 2.414.582M19.5 10v5c0 1.886 0 2.828-.586 3.414c-.471.472-1.174.564-2.414.582M13 14l-2.594 3.067c-.317.41-.475.616-.377.775c.098.158.383.158.954.158h2.034c.57 0 .856 0 .954.158c.098.159-.06.364-.377.775L10.984 22M10 2l-1 8m5-8l1 8M4 6h16" color="currentColor"/>' :
'<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" color="currentColor"><path d="M12 17h.009M20 8.5v5c0 3.771 0 5.657-1.172 6.828S15.771 21.5 12 21.5s-5.657 0-6.828-1.172S4 17.271 4 13.5v-5"/><path d="m22 10.5l-4.343-4.165C14.99 3.778 13.657 2.5 12 2.5S9.01 3.778 6.343 6.335L2 10.5"/></g>'
}
</svg>
</button>
` : ''}
</div>
</div>
<ol id="${childrenListId}" class="nested-sortable ${level > 0 ? 'nested-level-' + level : ''}">
${gallery.children && gallery.children.length > 0 ?
gallery.children.map(child => renderGalleryItem(child, level + 1, maxLevel)).join('') : ''}
</ol>
</li>
`;
return html;
}
// Additional helper function to add visual indicators during drag operations
function addDragVisualFeedback() {
// Add event listener for drag operations
document.addEventListener('dragover', function(e) {
// Find the closest nested sortable list
const nearestList = e.target.closest('.nested-sortable');
if (!nearestList) return;
// Remove active-drop-target class from all lists
document.querySelectorAll('.active-drop-target').forEach(el => {
el.classList.remove('active-drop-target');
});
// Add it to the current list
nearestList.classList.add('active-drop-target');
// Calculate if this would be a nested position
// This is a simplified calculation - SortableJS has its own logic,
// but this helps provide additional visual feedback
const rect = nearestList.getBoundingClientRect();
const distanceFromLeft = e.clientX - rect.left;
// If we're close to the left edge of a nested list, add a class
if (distanceFromLeft < 30) {
nearestList.classList.add('potential-parent');
} else {
nearestList.classList.remove('potential-parent');
}
});
// Remove classes when drag ends
document.addEventListener('dragend', function() {
document.querySelectorAll('.active-drop-target, .potential-parent').forEach(el => {
el.classList.remove('active-drop-target', 'potential-parent');
});
});
}
// Make sure the addDragVisualFeedback function is called when the document is ready
document.addEventListener('DOMContentLoaded', function() {
// Initialize the visual feedback helper
addDragVisualFeedback();
// If we're in edit mode, make sure the nested sortables are initialized
if (document.body.classList.contains('edit-mode-active')) {
setTimeout(function() {
initializeNestedSortables();
}, 300);
}
});
// Update the toggleEditMode function to ensure proper initialization of sortables
const originalToggleEditMode = window.toggleEditMode;
window.toggleEditMode = function() {
originalToggleEditMode.apply(this, arguments); // Call original function
stopAutoAdvanceTimer();
const sidebar = document.querySelector('.sidebar');
const editControls = document.querySelector('.edit-controls');
if (currentMenuLayout === 'horizontal') {
if (document.body.classList.contains('edit-mode-active')) {
// In horizontal layout and edit mode is ON: show sidebar and edit controls
if(sidebar) sidebar.style.display = 'block'; // Or 'flex' if it's a flex container
if(editControls) editControls.style.display = 'flex';
// Re-render the sidebar menu for editing
renderGalleries();
} else {
// In horizontal layout and edit mode is OFF: hide sidebar
if(sidebar) sidebar.style.display = 'none';
// Re-render the horizontal menu for viewing
renderHorizontalMenu();
}
} else {
// For other layouts (sidebar, top), ensure sidebar is visible
if(sidebar) sidebar.style.display = 'block'; // Or 'flex'
}
};
function verifyGalleryStructure() {
console.log("Verifying gallery structure integrity...");
// Count number of galleries
const galleryCount = galleries.length;
console.log(`Total galleries in flat array: ${galleryCount}`);
// Create tree and count galleries in tree
const tree = createGalleryTree([...galleries]);
let treeCount = 0;
function countGalleriesInTree(items) {
items.forEach(item => {
treeCount++;
if (item.children && item.children.length > 0) {
countGalleriesInTree(item.children);
}
});
}
countGalleriesInTree(tree);
console.log(`Total galleries in tree structure: ${treeCount}`);
if (treeCount !== galleryCount) {
console.warn(`GALLERY COUNT MISMATCH: ${galleryCount} in array vs ${treeCount} in tree`);
return false;
} else {
console.log("Gallery structure is consistent");
return true;
}
}
function renderGalleries() {
console.log("Rendering galleries...");
// Align with the explicit edit toggle only (not admin login / stale sidebar.editing)
if (document.body.classList.contains('edit-mode-active')) {
isEditing = true;
} else if (typeof window.isEditing === 'boolean') {
isEditing = window.isEditing;
}
var galleriesSource = galleries;
if (window.HelpSystem && typeof window.HelpSystem.getRenderGalleries === 'function') {
var helpList = window.HelpSystem.getRenderGalleries();
if (helpList) galleriesSource = helpList;
}
try {
// Only fix circular references if absolutely necessary
const foundCircular = fixCircularReferences();
// Only create a new tree if we had to fix circular references
let galleryTree;
if (foundCircular) {
console.log("Creating fresh gallery tree after fixing circular references");
galleryTree = createGalleryTree(galleriesSource);
} else {
console.log("Using existing structure to create gallery tree");
galleryTree = createGalleryTree(galleriesSource);
}
// Render the tree - IMPORTANT: Add null check
const tree = document.getElementById('galleryTree');
if (!tree) {
console.warn('galleryTree element not found, cannot render galleries');
return; // Exit the function if the element doesn't exist
}
// Render the tree with maximum depth protection
tree.innerHTML = `
<ol class="nested-sortable">
${galleryTree.map(gallery => renderGalleryItem(gallery, 0, 20)).join('')}
</ol>
`;
// Initialize sortable for all nested sortable elements
if (isEditing) {
// Use requestAnimationFrame to ensure DOM is fully updated before initializing sortables
// This prevents drag issues when items are added and immediately dragged
requestAnimationFrame(() => {
initializeNestedSortables();
});
}
console.log("Galleries rendered successfully");
// Set up folder toggle handler after rendering (works in both view and edit modes)
setupFolderToggleHandler();
// Verify structure integrity after rendering
verifyGalleryStructure();
if (typeof syncSeoNavA11y === 'function') syncSeoNavA11y();
} catch (error) {
console.error("Error rendering galleries:", error);
}
}
/** Prefer visible menu links for AT; keep SEO nav in DOM for crawlers. */
function syncSeoNavA11y() {
const seoNav = document.querySelector('.hydra-seo-nav');
if (!seoNav) return;
const hasVisibleLinks =
document.querySelectorAll('#galleryTree a.menu-item').length > 0 ||
document.querySelectorAll('.horizontal-menu-container a.horizontal-menu-label').length > 0;
const hideFromAt = hasVisibleLinks;
seoNav.setAttribute('aria-hidden', hideFromAt ? 'true' : 'false');
// aria-hidden alone is not enough — focusable descendants must leave the tab order
seoNav.querySelectorAll('a, button, [tabindex]').forEach((el) => {
if (hideFromAt) {
if (!el.hasAttribute('data-seo-prev-tabindex')) {
el.setAttribute('data-seo-prev-tabindex', el.hasAttribute('tabindex') ? el.getAttribute('tabindex') : '');
}
el.setAttribute('tabindex', '-1');
} else {
const prev = el.getAttribute('data-seo-prev-tabindex');
if (prev === null) return;
if (prev === '') el.removeAttribute('tabindex');
else el.setAttribute('tabindex', prev);
el.removeAttribute('data-seo-prev-tabindex');
}
});
}
function getMenuItemHref(galleryItem) {
if (!galleryItem) return '#';
if (galleryItem.isExternal && galleryItem.url) return galleryItem.url;
if (galleryItem.isHomePage) return '/';
if (galleryItem.url && String(galleryItem.url).startsWith('/')) return galleryItem.url;
if (galleryItem.slug) return '/' + galleryItem.slug;
return '#';
}
function fixCircularReferences() {
console.log("Selectively checking for circular references...");
// Only process galleries that need checking:
// 1. Imported galleries (they're new and might cause issues)
// 2. Galleries with extremely deep nesting
// 3. Actual circular references
const processed = new Set();
// First pass: Identify actual circular references without modifying structure
const circularIds = new Set();
const suspiciousIds = new Set();
galleries.forEach(gallery => {
if (!gallery || !gallery.id) return;
// Skip galleries we've already processed
if (processed.has(gallery.id)) return;
// Check for self-reference - this is always wrong
if (gallery.parentId === gallery.id) {
console.warn(`Self-reference detected: Gallery ${gallery.title} (${gallery.id}) is its own parent`);
circularIds.add(gallery.id);
return;
}
// Skip galleries without parents
if (!gallery.parentId) {
processed.add(gallery.id);
return;
}
// Follow the parent chain to detect circular references
const parentChain = [gallery.id];
let currentId = gallery.parentId;
let loopCount = 0;
let foundCircular = false;
while (currentId && loopCount < 20) { // Reasonable limit to prevent infinite loops
loopCount++;
// If we've seen this ID before, it's a circular reference
if (parentChain.includes(currentId)) {
console.warn(`Circular reference detected in chain for gallery: ${gallery.title}`);
circularIds.add(gallery.id);
foundCircular = true;
break;
}
parentChain.push(currentId);
// Move up to the next parent
const parent = galleries.find(g => g.id === currentId);
currentId = parent?.parentId;
// If we can't find the parent, we can stop
if (!parent) break;
}
// If we hit our loop limit but didn't find a circular reference,
// it's suspicious but not definitely circular
if (loopCount >= 20 && !foundCircular) {
console.warn(`Suspiciously deep parent chain for gallery ${gallery.title}, marking for review`);
suspiciousIds.add(gallery.id);
}
// Mark this gallery and all its ancestors as processed
parentChain.forEach(id => processed.add(id));
});
// Second pass: Only fix the identified circular references
if (circularIds.size > 0) {
console.log(`Fixing ${circularIds.size} confirmed circular references`);
galleries.forEach(gallery => {
if (circularIds.has(gallery.id)) {
console.log(`Breaking circular reference for gallery: ${gallery.title}`);
gallery.parentId = null;
}
});
} else {
console.log("No circular references found");
}
// For suspicious galleries, don't automatically fix them
if (suspiciousIds.size > 0) {
console.warn(`Found ${suspiciousIds.size} galleries with unusually deep nesting - not fixing automatically`);
}
return circularIds.size > 0; // Return true if we fixed anything
}
// Deep linking implementation that uses the existing slug property
// Function to get the slug for a gallery
function getGallerySlug(gallery) {
// First try to use the existing slug property
if (gallery.slug) {
console.log(`Using existing gallery slug: ${gallery.slug}`);
return gallery.slug;
}
// If no slug exists, generate one from the title as fallback
if (gallery.title) {
const generatedSlug = gallery.title
.toLowerCase()
.replace(/[^ws-]/g, '')
.replace(/s+/g, '-')
.replace(/--+/g, '-')
.trim();
console.log(`Generated slug from title: ${generatedSlug}`);
return generatedSlug || 'gallery';
}
// Default fallback
console.warn('Gallery has no slug or title, using default');
return 'gallery';
}
// Function to update the URL when a gallery is loaded
// Update the URL when a gallery is loaded
function updateURLWithGallerySlug(gallery) {
if (!gallery) return;
// Keep bookmarkable /_manage URL — do not rewrite to / or /slug when loading galleries/pages
const __hydraPath = window.location.pathname;
if (__hydraPath === '/_manage' || __hydraPath === '/_manage/') {
console.log('[updateURLWithGallerySlug] On /_manage editor path; skipping slug URL update.');
return;
}
// Get the slug for this gallery/page
let slug = gallery.slug;
if (!slug && gallery.title) {
slug = gallery.title.toLowerCase().replace(/s+/g, '-').replace(/[^w-]+/g, '').replace(/--+/g, '-').trim();
}
// Check if we're in preview mode (add this new code)
const isPreviewMode = window.location.hostname === 'preview.neonsky.app';
// Check if current history state already has this galleryId
const currentState = window.history.state;
const currentPath = window.location.pathname;
const isAlreadyCurrentPage = currentState && currentState.galleryId === gallery.id &&
((isPreviewMode && currentPath.includes(slug)) || (!isPreviewMode && currentPath === '/' + slug));
if (isPreviewMode) {
// Extract GUID from current URL path - first segment after domain
const pathParts = window.location.pathname.split('/').filter(Boolean);
const siteGuid = pathParts[0];
// Create history entry if we have a valid GUID, and it's not already the current state.
// Home pages use /{guid}/ (canonical preview home), matching live's '/' special-case.
if (siteGuid && (gallery.isHomePage || slug)) {
const targetPath = gallery.isHomePage ? ('/' + siteGuid + '/') : ('/' + siteGuid + '/' + slug);
const alreadyAtPreviewHome = gallery.isHomePage && (
currentPath === '/' + siteGuid || currentPath === '/' + siteGuid + '/'
);
// Only push if URL is different OR if state doesn't match (allows multiple navigations)
if ((!alreadyAtPreviewHome && currentPath !== targetPath) || !isAlreadyCurrentPage) {
console.log('🔍 [updateURLWithGallerySlug] Updating URL to ' + targetPath + ' (preview mode), galleryId: ' + gallery.id);
// Update browser URL without reloading, preserving the GUID
window.history.pushState(
{ galleryId: gallery.id },
gallery.title || 'Gallery',
targetPath
);
// Update page title
document.title = formatGalleryDocumentTitle(gallery.title);
} else {
console.log('🔍 [updateURLWithGallerySlug] URL and state already match, skipping history update (preview mode)');
}
}
return; // Exit early, we've handled the preview case
}
// Regular (non-preview) URL handling
if (slug) {
// If this gallery is the designated home page, keep the URL as '/' (canonical home URL)
const targetPath = gallery.isHomePage ? '/' : '/' + slug;
// Only push if URL is different OR if state doesn't match (allows multiple navigations)
if (currentPath !== targetPath || !isAlreadyCurrentPage) {
console.log('🔍 [updateURLWithGallerySlug] Updating URL to ' + targetPath + ', galleryId: ' + gallery.id);
window.history.pushState(
{ galleryId: gallery.id },
gallery.title || 'Gallery',
targetPath
);
document.title = formatGalleryDocumentTitle(gallery.title);
} else {
console.log('🔍 [updateURLWithGallerySlug] URL and state already match, skipping history update');
}
}
}
function loadHomePage() {
// Find a gallery marked as home page (regardless of visibility)
const homePage = galleries.find(gallery => gallery.isHomePage === true);
if (homePage) {
console.log(`Loading home page: ${homePage.title} (Visible: ${homePage.visible !== false})`);
// Update active gallery ID
activeGalleryId = homePage.id;
// Add a special flag to indicate we're loading the home page
// This allows us to bypass visibility checks
window._loadingHomePage = true;
// Determine if this is a page or gallery and load appropriately
if (homePage.isPage) {
loadPage(homePage.id);
} else {
loadGallery(homePage.id);
}
// Clear the flag after loading
setTimeout(() => {
window._loadingHomePage = false;
}, 100);
// Update UI state
updateActiveStates();
updateMobileTitle();
if (typeof closeMobileMenu === 'function') closeMobileMenu();
return true;
}
console.log('No home page defined');
return false;
}
// 4. Update the handleURLNavigation function to check for home page
// Find the existing handleURLNavigation function and modify as follows:
// In index.ts
function handleURLNavigation() {
const path = window.location.pathname.substring(1); // Remove leading slash
const isPreviewMode = window.location.hostname === 'preview.neonsky.app';
let slug = path;
let siteGuid = null;
// /_manage is the Hydra editor entry — treat like site root for slug resolution (not a gallery slug)
if (!isPreviewMode && (path === '_manage' || path.startsWith('_manage/'))) {
console.log('[handleURLNavigation] /_manage detected — using root navigation behavior');
slug = '';
}
if (isPreviewMode && path) {
const pathParts = path.split('/');
if (pathParts.length >= 1) {
siteGuid = pathParts[0]; // Path could be just GUID/ or GUID/slug
}
if (pathParts.length >= 2) {
slug = pathParts[1]; // Slug is the part after GUID
console.log(`Preview URL detected, using slug: "${slug}" under GUID: "${siteGuid}"`);
} else {
// Only GUID is present, or path is empty after GUID
slug = ''; // Treat as root/home for the given GUID
console.log(`Preview URL with only GUID: "${siteGuid}", checking for home page or default.`);
}
}
if (!slug) { // Handles root path ('/') or preview URL with only GUID ('/GUID/')
console.log("No specific slug in path (root or GUID-only preview), checking for home page.");
if (!loadHomePage() && galleries.length > 0) {
const firstVisibleGallery = galleries.find(g => g.visible !== false && !g.isSpacer && !g.isFolder && !g.isSubmenu);
if (firstVisibleGallery) {
console.log("No home page, loading first visible item:", firstVisibleGallery.title);
if (firstVisibleGallery.isPage) {
loadPage(firstVisibleGallery.id);
} else {
loadGallery(firstVisibleGallery.id);
}
} else {
console.log("No home page and no visible items to load for this path.");
}
}
} else {
console.log(`Handling URL navigation for slug: "${slug}"` + (isPreviewMode ? ` (Preview GUID: ${siteGuid})` : ""));
// findGalleryByPath should be able to find the item using the slug,
// handling preview mode internally if necessary or by being passed the correct slug.
const matchingGallery = findGalleryByPath(slug, isPreviewMode, siteGuid);
if (matchingGallery) {
console.log('Loading content from URL: "' + matchingGallery.title + '" (ID: ' + matchingGallery.id + ')');
console.log('🔍 [handleURLNavigation] Gallery properties - isPage:', matchingGallery.isPage, 'pageId:', matchingGallery.pageId, 'has pageElements:', !!(matchingGallery.pageElements && Array.isArray(matchingGallery.pageElements)));
if (matchingGallery.isSubmenu) {
console.log('Gallery is a submenu, skipping direct load. Parent expansion might be needed.');
// Potentially, you might want to find and expand its parent here if the UI supports it.
} else {
// Set activeGalleryId *before* calling loadPage/loadGallery
activeGalleryId = matchingGallery.id;
window.activeGalleryId = matchingGallery.id;
// Determine if this is a page by checking multiple indicators
// Check isPage flag, pageId, or pageElements array
const isPage = matchingGallery.isPage === true ||
(matchingGallery.pageId && matchingGallery.pageId.startsWith('page_')) ||
(matchingGallery.pageElements && Array.isArray(matchingGallery.pageElements) && matchingGallery.pageElements.length > 0);
if (isPage) {
console.log('🔍 [handleURLNavigation] Detected as PAGE, loading page: "' + matchingGallery.title + '"');
const pageLoader = typeof window.loadPage === 'function' ? window.loadPage : loadPage;
pageLoader(matchingGallery.id);
} else {
console.log('🔍 [handleURLNavigation] Detected as GALLERY, loading gallery: "' + matchingGallery.title + '"');
const galleryLoader = typeof window.loadGallery === 'function' ? window.loadGallery : loadGallery;
galleryLoader(matchingGallery.id, null, { forceReload: true });
}
}
} else {
console.log(`No matching gallery found for slug: "${slug}". Checking for home page as fallback.`);
if (!loadHomePage()) { // Try loading home page
const firstVisibleGallery = galleries.find(g => g.visible !== false && !g.isSpacer && !g.isFolder && !g.isSubmenu);
if (firstVisibleGallery) {
console.log(`Loading first visible gallery instead: "${firstVisibleGallery.title}"`);
if (firstVisibleGallery.isPage) {
loadPage(firstVisibleGallery.id);
} else {
loadGallery(firstVisibleGallery.id);
}
} else {
console.log("No matching gallery, no home page, and no visible items to load.");
// Optionally, display a 404 message in the gallery-container
const galleryContainer = document.querySelector('.gallery-container');
if (galleryContainer) {
galleryContainer.innerHTML = '<div>Page not found.</div>';
galleryContainer.style.opacity = '1';
}
}
}
}
}
// Update UI states after initiating load.
// These functions should ideally use the now-set activeGalleryId.
if (typeof updateActiveStates === 'function') updateActiveStates(); // For sidebar/tree
if (typeof updateMobileTitle === 'function') updateMobileTitle(); // For mobile header
// **** ADDED FOR DEEP LINKING HORIZONTAL MENU ****
// Determine current layout (it might not be set by MenuStyleCustomizer yet on direct load)
let currentLayout = 'sidebar'; // Default assumption
if (window.MenuStyleCustomizer && window.MenuStyleCustomizer.settings && window.MenuStyleCustomizer.settings.menuLayout) {
currentLayout = window.MenuStyleCustomizer.settings.menuLayout;
} else {
// Fallback: check body class if MenuStyleCustomizer hasn't initialized settings
if (document.body.classList.contains('menu-layout-horizontal')) {
currentLayout = 'horizontal';
}
}
console.log(`handleURLNavigation: Determined current layout as: ${currentLayout}`);
if (currentLayout === 'horizontal') {
if (typeof updateActiveStatesHorizontal === 'function') {
console.log("handleURLNavigation: Explicitly calling updateActiveStatesHorizontal for horizontal layout on direct navigation.");
// It's possible menu items aren't rendered yet by renderHorizontalMenu if this is a very early call.
// A small delay might be needed, or ensure renderHorizontalMenu has run.
// For now, call it directly. If items aren't there, it won't do anything harmful.
setTimeout(() => { // Add a slight delay to allow menu rendering
updateActiveStatesHorizontal();
}, 100); // Adjust delay if needed, or find a more robust way to ensure menu is rendered.
} else {
console.warn("handleURLNavigation: updateActiveStatesHorizontal function not found for horizontal layout.");
}
}
}
/**
* Enhanced loadPage function that properly cleans up gallery content
* @param {number} id - The page ID
* @param {Event} event - Optional event object
*/
window.loadPage = function(id, event) {
if (event) {
event.preventDefault();
event.stopPropagation();
const now = Date.now();
const lastCallTime = window._lastPageLoadTime || 0;
window._lastPageLoadTime = now;
if (now - lastCallTime < 100) {
console.log('Ignoring duplicate loadPage call');
return;
}
}
console.error('🔍 [loadPage] Loading page with gallery ID:', id);
stopAutoAdvanceTimer();
let galleriesData = window.galleries || galleries;
const gallery = findGalleryById(galleriesData, id);
if (!gallery) {
console.warn('No gallery found with ID:', id, 'for page load.');
return;
}
if (typeof window.removeGalleryScriptsWithPause === 'function') {
window.removeGalleryScriptsWithPause();
}
window.activeGalleryId = id;
activeGalleryId = id;
window._activeContentType = 'page';
const galleryContainer = document.querySelector('.gallery-container');
if (galleryContainer) {
// Clear the container but ensure it's visible
galleryContainer.innerHTML = '';
galleryContainer.style.display = 'block';
galleryContainer.style.visibility = 'visible';
galleryContainer.style.opacity = '1';
console.log('🔍 [loadPage] Cleared and ensured gallery-container is visible');
} else {
console.error('🔍 [loadPage] gallery-container not found!');
}
if (!gallery.pageId) gallery.pageId = `page_${id}`;
if (!gallery.isPage) gallery.isPage = true;
if (typeof window.syncGalleryOptionsButtonVisibility === 'function') {
window.syncGalleryOptionsButtonVisibility();
}
if (!window._pageIdToGalleryId) window._pageIdToGalleryId = {};
window._pageIdToGalleryId[gallery.pageId] = id;
const seedPageElementsOntoManager = function() {
if (gallery.pageElements && Array.isArray(gallery.pageElements)) {
if (window.PageManager && window.PageManager.elements) {
window.PageManager.elements[gallery.pageId] = [...gallery.pageElements];
}
} else if (window.PageManager && window.PageManager.elements &&
window.PageManager.elements[gallery.pageId] &&
window.PageManager.elements[gallery.pageId].length > 0) {
gallery.pageElements = [...window.PageManager.elements[gallery.pageId]];
}
};
seedPageElementsOntoManager();
const invokePageManagerLoad = function() {
seedPageElementsOntoManager();
try {
console.error('🔍 [loadPage] Calling PageManager.loadPage for pageId:', gallery.pageId);
console.error('🔍 [loadPage] Gallery container exists:', !!galleryContainer, 'Container HTML length:', galleryContainer ? galleryContainer.innerHTML.length : 0);
console.error('🔍 [loadPage] Page elements count:', gallery.pageElements ? gallery.pageElements.length : 0);
console.error('🔍 [loadPage] PageManager.elements[' + gallery.pageId + '] exists:', !!(window.PageManager.elements && window.PageManager.elements[gallery.pageId]));
// Small delay to ensure DOM is ready, especially when navigating back via history
setTimeout(() => {
// Verify container still exists
const containerCheck = document.querySelector('.gallery-container');
console.error('🔍 [loadPage] Container check before loadPage:', !!containerCheck, 'Same as original:', containerCheck === galleryContainer);
// PageManager.loadPage is async - we need to properly handle it
try {
const loadPromise = window.PageManager.loadPage(gallery.pageId);
console.error('🔍 [loadPage] PageManager.loadPage called, returned:', typeof loadPromise, 'is promise:', loadPromise && typeof loadPromise.then === 'function');
if (loadPromise && typeof loadPromise.then === 'function') {
loadPromise.then(() => {
console.error('🔍 [loadPage] PageManager.loadPage promise resolved');
if (typeof window.syncGalleryOptionsButtonVisibility === 'function') {
window.syncGalleryOptionsButtonVisibility();
}
// Ensure container visibility after PageManager has initialized
if (galleryContainer) {
const pageContainer = galleryContainer.querySelector('.page-container');
if (pageContainer) {
pageContainer.style.display = 'block';
pageContainer.style.visibility = 'visible';
pageContainer.style.opacity = '1';
const elementCount = pageContainer.querySelectorAll('.page-element').length;
console.error('🔍 [loadPage] Page container found and made visible, has', elementCount, 'elements');
if (elementCount === 0) {
console.error('🔍 [loadPage] WARNING: Page container exists but has no elements!');
console.error('🔍 [loadPage] PageManager.elements[' + gallery.pageId + ']:', window.PageManager.elements[gallery.pageId]);
}
} else {
console.error('❌ [loadPage] Page container not found after PageManager.loadPage');
console.error('❌ [loadPage] Gallery container HTML:', galleryContainer.innerHTML.substring(0, 500));
console.error('❌ [loadPage] Gallery container children:', galleryContainer.children.length);
}
} else {
console.error('❌ [loadPage] Gallery container is null after PageManager.loadPage');
}
}).catch((error) => {
console.error('❌ [loadPage] Error in PageManager.loadPage promise:', error);
console.error('❌ [loadPage] Error stack:', error.stack);
});
} else {
// If loadPage doesn't return a promise, check after a delay
console.error('🔍 [loadPage] loadPage did not return a promise, checking after delay');
setTimeout(() => {
if (galleryContainer) {
const pageContainer = galleryContainer.querySelector('.page-container');
if (pageContainer) {
pageContainer.style.display = 'block';
pageContainer.style.visibility = 'visible';
pageContainer.style.opacity = '1';
console.error('🔍 [loadPage] Ensured page-container is visible (non-promise path)');
} else {
console.error('❌ [loadPage] Page container not found (non-promise path)');
console.error('❌ [loadPage] Gallery container HTML:', galleryContainer.innerHTML.substring(0, 500));
}
}
}, 200);
}
} catch (loadError) {
console.error('❌ [loadPage] Exception calling PageManager.loadPage:', loadError);
console.error('❌ [loadPage] Error stack:', loadError.stack);
}
const isCurrentlyEditing = typeof isInEditMode === 'function' ? isInEditMode() : false;
if (isCurrentlyEditing && typeof window.PageManager.setEditMode === 'function') {
window.PageManager.setEditMode(isCurrentlyEditing);
}
}, 50); // Small delay to ensure DOM is ready
} catch (error) {
console.error('❌ [loadPage] Error in loadPage function:', error);
console.error('❌ [loadPage] Error stack:', error.stack);
}
};
if (window.PageManager && typeof window.PageManager.loadPage === 'function') {
invokePageManagerLoad();
} else {
console.error('❌ [loadPage] PageManager not found yet — waiting for CDN proxy script');
console.error('❌ [loadPage] window.PageManager:', !!window.PageManager);
console.error('❌ [loadPage] typeof loadPage:', window.PageManager ? typeof window.PageManager.loadPage : 'N/A');
whenPageManagerReady(function() {
console.log('✅ [loadPage] PageManager became available — retrying page render');
invokePageManagerLoad();
});
}
// Apply menu visibility
const bodyEl = document.body;
if (shouldApplyMenuHiddenForEmbedOrGallery(gallery)) {
bodyEl.classList.add('menu-hidden-on-page');
} else {
bodyEl.classList.remove('menu-hidden-on-page'); // Ensure menu is visible if in edit mode or not hidden
}
if (typeof updateActiveStates === 'function') updateActiveStates();
if (typeof updateMobileTitle === 'function') updateMobileTitle();
if (typeof closeMobileMenu === 'function') closeMobileMenu();
// Close the gallery options panel when navigating to a different page
if (typeof window.closeOptionsPanel === 'function') {
window.closeOptionsPanel();
}
if (typeof updateURLWithGallerySlug === 'function') updateURLWithGallerySlug(gallery);
if (typeof window.syncGalleryOptionsButtonVisibility === 'function') {
window.syncGalleryOptionsButtonVisibility();
}
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
window.dispatchEvent(new CustomEvent('page-loaded', {
detail: { pageId: gallery.pageId, galleryId: id }
}));
}
}
/**
* Ensures all required gallery styles are loaded
*/
function ensureGalleryStylesLoaded() {
// Use Worker route for gallery CSS (avoids CDN SSL issues)
const galleryScriptsBase = window.location.origin + '/gallery-scripts/';
const requiredStyles = [
{ id: 'neon-gallery-css', href: galleryScriptsBase + 'neon-gallery-hp-hydra-v260814-001.css' },
{ id: 'quill-css', href: 'https://cdn.quilljs.com/1.3.6/quill.snow.css' }
];
console.log('Loading gallery CSS from Worker:', galleryScriptsBase + 'neon-gallery-hp-hydra-v260814-001.css');
requiredStyles.forEach(style => {
if (!document.getElementById(style.id)) {
const link = document.createElement('link');
link.id = style.id;
link.rel = 'stylesheet';
link.href = style.href;
if (style.href.includes('cdn.neonsky.app')) {
link.onerror = function() {
console.error('❌ Gallery CSS failed, retrying via proxy:', link.href);
// Set flag if not already set
if (!window.useCdnProxy) {
window.useCdnProxy = true;
console.error('🚩 CDN_PROXY_FLAG SET: Gallery CSS failed, using proxy for all subsequent requests');
}
const proxyUrl = link.href.replace('https://cdn.neonsky.app/', window.location.origin + '/cdn-proxy/');
link.href = proxyUrl;
link.onerror = function() {
console.error('❌ Proxy failed, trying storage:', proxyUrl);
link.href = link.href.replace('cdn.neonsky.app', 'storage.neonsky.app');
};
};
}
document.head.appendChild(link);
console.log(`Added gallery style: ${style.id}`);
}
});
}
/**
* Ensures all required gallery scripts are loaded
* @returns {Promise} A promise that resolves when all scripts are loaded
*/
function ensureGalleryScriptsLoaded() {
const requiredScripts = [
{ id: 'crypto-js', src: 'https://cdn.jsdelivr.net/npm/crypto-js@4.1.1/crypto-js.min.js' },
{ id: 'quill-js', src: 'https://cdn.quilljs.com/1.3.6/quill.min.js' },
{ id: 'quill-integration', src: 'https://cdn.neonsky.app/quill-integration-v260814-001.js' }
];
const promises = requiredScripts.map(script => {
return new Promise((resolve, reject) => {
// If script is already loaded, resolve immediately
if (document.getElementById(script.id)) {
resolve();
return;
}
// Create and load the script
const scriptElement = document.createElement('script');
scriptElement.id = script.id;
scriptElement.src = script.src;
scriptElement.onload = resolve;
scriptElement.onerror = () => reject(new Error(`Failed to load script: ${script.src}`));
document.head.appendChild(scriptElement);
});
});
return Promise.all(promises);
}
/**
* Creates a clean gallery context without using an iframe
* @param {Object} gallery - The gallery object
* @param {HTMLElement} container - The container to load the gallery into
*/
function createGalleryContext(gallery, container) {
console.log('Creating gallery context for:', gallery.title);
// CRITICAL: Ensure gallery CSS is loaded BEFORE loading the gallery script
// This ensures styles are available even if NDJSON fails and falls back to classic JSON
ensureGalleryStylesLoaded();
// Generate the page alias safely
const pageAlias = gallery.slug || gallery.title.toLowerCase().replace(/s+/g, '-');
// Get our gallery options
const galleryOptions = gallery.galleryOptions || {};
// Get token data
let authToken = null;
// First try TokenManager if available
if (window.TokenManager && typeof window.TokenManager.getToken === 'function') {
authToken = window.TokenManager.getToken();
}
// Then try global token
else if (window.didToken) {
authToken = window.didToken;
}
// Set up Parameters for the gallery
const galleryParameters = {
SiteAlias: window.location.hostname,
InitialPageUuid: gallery.pageId,
InitialPageAlias: pageAlias,
isInEditor: window.isEditing || false,
siteId: window.siteId || '',
isHydra: true
};
// If we have a token, add it
if (authToken) {
galleryParameters.hydraAuthToken = authToken;
}
// Preserve original window.Parameters
const originalParameters = window.Parameters;
// REMOVED: Loading indicator - no longer showing the loading animation
// Load the main gallery script - Use Worker route (avoids CDN SSL issues)
const galleryScript = document.createElement('script');
const galleryScriptsBase = window.location.origin + '/gallery-scripts/';
galleryScript.src = galleryScriptsBase + 'neon-gallery-main-v260814-001.js';
console.log('Loading gallery-main script from Worker:', galleryScript.src);
galleryScript.onerror = function() {
console.error('❌ Gallery script failed, retrying via proxy:', galleryScript.src);
const proxyUrl = galleryScript.src.replace('https://cdn.neonsky.app/', window.location.origin + '/cdn-proxy/');
galleryScript.src = proxyUrl;
galleryScript.onerror = function() {
console.error('❌ Proxy failed, trying storage:', proxyUrl);
galleryScript.src = galleryScript.src.replace('cdn.neonsky.app', 'storage.neonsky.app');
};
};
// Setup the gallery configuration
const neonGalleryConfig = {
useData: true,
useCDN: true,
version: 'live',
manualCollectionName: "mod",
layoutType: "grid",
...galleryOptions,
siteId: window.siteId || ''
};
// Execute in a safe way that minimizes global namespace pollution
try {
// Set up the global variables needed by the gallery script
window.Parameters = galleryParameters;
window.neonGalleryConfig = neonGalleryConfig;
// Monitor for script load/error
galleryScript.onload = () => {
console.log('Gallery script loaded successfully for:', gallery.title);
// REMOVED: Don't need to remove the loading indicator since we don't add it
};
galleryScript.onerror = () => {
console.error('Failed to load gallery script for:', gallery.title);
//container.innerHTML = '<div class="gallery-error">Error loading gallery. Please try again later.</div>';
// Restore original parameters
window.Parameters = originalParameters;
};
// Add the script to load the gallery
document.body.appendChild(galleryScript);
// Set up a cleanup function that will be called when another gallery is loaded
container.cleanup = () => {
// Restore original parameters when cleaning up
window.Parameters = originalParameters;
// Remove the gallery script to prevent conflicts
galleryScript.remove();
// Clear main gallery container
if (container.parentNode) {
container.innerHTML = '';
}
};
} catch (error) {
console.error('Error creating gallery context:', error);
//container.innerHTML = '<div class="gallery-error">Error initializing gallery. Please try again later.</div>';
// Restore original parameters
window.Parameters = originalParameters;
}
}
/**
* Only used for the PageManager's initializeDOM - ensures clean content swap
*/
function cleanGalleryContainer() {
const galleryContainer = document.querySelector('.gallery-container');
if (galleryContainer) {
galleryContainer.innerHTML = '';
}
}
/**
* Initializes the page using the PageManager
* To be used by PageManager.initializeDOM
*/
function initPageContent(pageId) {
// First clear the container completely
cleanGalleryContainer();
// Then load the page content
if (window.PageManager && window.PageManager.loadPage) {
window.PageManager.loadPage(pageId);
}
}
/**
* Cleanup gallery content when changing between galleries or pages
*/
function cleanupGalleryContent() {
// Get the gallery container
const galleryContainer = document.querySelector('.gallery-container');
if (!galleryContainer) return;
// Find the direct gallery content
const directContent = galleryContainer.querySelector('.gallery-direct-content');
if (directContent && typeof directContent.cleanup === 'function') {
// Call the cleanup function
directContent.cleanup();
}
// Clear the container
galleryContainer.innerHTML = '';
}
/**
* Simplified loadGallery function that follows the same content swap pattern
* @param {number} id - The gallery ID
* @param {Event} event - Optional event object
*/
// Override loadGallery to update URL
window.loadGallery = async function(id, event) {
// Stop event propagation if provided
if (event) {
event.stopPropagation();
}
const galleryContainer = document.querySelector('.gallery-container');
if (galleryContainer) {
// Apply immediate hiding styles
galleryContainer.style.opacity = '0';
}
console.log('Loading gallery with ID:', id);
stopAutoAdvanceTimer();
// Find gallery by ID
const gallery = findGalleryById(galleries, id); // Assuming `galleries` is accessible
if (!gallery) {
console.warn('No gallery found with ID:', id);
if (galleryContainer) {
galleryContainer.style.opacity = '1';
} // Show container if gallery not found
return;
}
// Check if this is a page, if so use loadPage instead
if (isPageMenuItem(gallery)) {
// Use the loadPage function defined in this scope (or window.loadPage if you prefer)
return loadPage(id, event);
}
// Skip submenu items
if (gallery.isSubmenu) {
console.log('Gallery is submenu, skipping URL update');
toggleSubmenu(id, event || { stopPropagation: () => {} }); // Assuming toggleSubmenu is available
if (galleryContainer) {
galleryContainer.style.opacity = '1';
} // Show container
return;
}
console.log('Found gallery:', gallery.title);
// CRITICAL: Set active gallery ID consistently in both global and window scope
console.log('Setting active gallery ID to:', id, '(Previous value:', (window.activeGalleryId || activeGalleryId), ')');
window.activeGalleryId = id;
activeGalleryId = id; // Ensure both variables are synchronized
// Save reference to which gallery we're loading (for later verification)
window.currentLoadingGalleryId = id;
// CRITICAL: Thorough cleanup with await to ensure completion
if (typeof window.removeGalleryScriptsWithPause === 'function') {
await window.removeGalleryScriptsWithPause();
}
// Clear all existing content from gallery container
if (!galleryContainer) {
console.error('Gallery container not found');
return;
}
// Force clear all content
galleryContainer.innerHTML = '';
// CRITICAL: Double-check active gallery ID before updating UI
if (window.activeGalleryId !== id || activeGalleryId !== id) {
console.warn('Active gallery ID changed unexpectedly before UI update, restoring to:', id);
window.activeGalleryId = id;
activeGalleryId = id;
}
// Apply menu visibility based on the gallery's setting and edit mode
const bodyEl = document.body;
if (shouldApplyMenuHiddenForEmbedOrGallery(gallery)) {
bodyEl.classList.add('menu-hidden-on-page');
} else {
bodyEl.classList.remove('menu-hidden-on-page'); // Ensure menu is visible if in edit mode or not hidden
}
// Update UI state using explicit window function references or local fallbacks
if (typeof window.updateActiveStates === 'function') {
window.updateActiveStates();
} else if (typeof updateActiveStates === 'function') {
updateActiveStates();
}
if (typeof window.updateMobileTitle === 'function') {
window.updateMobileTitle();
} else if (typeof updateMobileTitle === 'function') {
updateMobileTitle();
}
if (typeof window.closeMobileMenu === 'function') {
window.closeMobileMenu();
} else if (typeof closeMobileMenu === 'function') {
closeMobileMenu();
}
// Update URL with gallery slug
if (typeof window.updateURLWithGallerySlug === 'function') {
window.updateURLWithGallerySlug(gallery);
} else if (typeof updateURLWithGallerySlug === 'function') {
updateURLWithGallerySlug(gallery);
}
// Create gallery container and load content
const neonGalleryContainer = document.createElement('div');
neonGalleryContainer.id = 'neon-gallery-container';
neonGalleryContainer.className = 'gallery-direct-content';
neonGalleryContainer.setAttribute('data-gallery-id', gallery.id.toString());
neonGalleryContainer.style.width = '100%';
neonGalleryContainer.style.height = '100%';
galleryContainer.appendChild(neonGalleryContainer);
// Add a cache-busting parameter to ensure script is freshly loaded
const timestamp = Date.now();
// Set up gallery parameters
window.Parameters = {
SiteAlias: window.location.hostname,
InitialPageUuid: gallery.pageId,
InitialPageAlias: gallery.slug || slugify(gallery.title), // Assuming slugify is available
isInEditor: window.isEditing || false, // Assuming window.isEditing is available
siteId: window.siteId || gallery.siteId || '', // Assuming window.siteId is available
isHydra: true,
galleryInstanceId: timestamp,
// Added loadedGalleryId as per your original function
loadedGalleryId: gallery.id
};
// Debug: Check what galleryOptions contains
console.error('🔍 INDEX.TS DEBUG - Gallery:', gallery.title, 'has isPerma:', gallery.galleryOptions?.isPerma, 'permaURL:', gallery.galleryOptions?.permaURL, 'loadTxId:', gallery.galleryOptions?.loadTxId, 'loadPermaURL:', gallery.galleryOptions?.loadPermaURL);
// Set up gallery config
window.neonGalleryConfig = {
useData: true,
useCDN: true,
version: 'live',
manualCollectionName: gallery.galleryOptions?.manualCollectionName || "mod",
layoutType: gallery.galleryOptions?.layoutType || "grid",
// Spread gallery options AFTER setting defaults to ensure current gallery's settings take precedence
// This ensures that only properties from the current gallery are included, not from previous galleries
...(gallery.galleryOptions || {}),
// CRITICAL: Explicitly include Load Network fields to ensure they're passed to gallery script
// These fields are required for the gallery to determine if it should use Load Network racing
loadTxId: gallery.galleryOptions?.loadTxId || null,
loadPermaURL: gallery.galleryOptions?.loadPermaURL || null,
// Cloudflare CDN NDJSON (unique id) — when present, gallery loads CDN-first (3s) then Fly
cdnNdjsonId: gallery.galleryOptions?.cdnNdjsonId || null,
cdnNdjsonUrl: gallery.galleryOptions?.cdnNdjsonUrl || null,
cdnNdjsonStableUrl: gallery.galleryOptions?.cdnNdjsonStableUrl || gallery.galleryOptions?.embedPointerUrl || null,
embedPointerUrl: gallery.galleryOptions?.embedPointerUrl || gallery.galleryOptions?.cdnNdjsonStableUrl || null,
cdnSaveTimestamp: gallery.galleryOptions?.cdnSaveTimestamp || null,
siteId: window.siteId || gallery.siteId || '', // Assuming window.siteId is available
// Added galleryId and galleryInstanceId as per your original function - these OVERRIDE spread
galleryId: gallery.id,
galleryInstanceId: timestamp
};
console.error('🔍 INDEX.TS DEBUG - After spread, window.neonGalleryConfig.isPerma:', window.neonGalleryConfig.isPerma, 'permaURL:', window.neonGalleryConfig.permaURL, 'loadTxId:', window.neonGalleryConfig.loadTxId, 'loadPermaURL:', window.neonGalleryConfig.loadPermaURL, 'cdnNdjsonId:', window.neonGalleryConfig.cdnNdjsonId);
// Create and add the gallery script with cache-busting
// Use Worker route (avoids CDN SSL issues)
const script = document.createElement('script');
const galleryScriptsBase = window.location.origin + '/gallery-scripts/';
script.src = galleryScriptsBase + 'neon-gallery-main-v260814-001.js'; // Using versioned file
script.setAttribute('data-gallery-id', gallery.id.toString()); // Keep data-gallery-id attribute
script.setAttribute('data-timestamp', timestamp.toString()); // Keep data-timestamp attribute
console.log('Loading gallery-main script from Worker:', script.src);
script.onerror = function() {
console.error('❌ Gallery script failed, retrying via proxy:', script.src);
const proxyUrl = script.src.replace('https://cdn.neonsky.app/', window.location.origin + '/cdn-proxy/');
script.src = proxyUrl;
script.onerror = function() {
console.error('❌ Proxy failed, trying storage:', proxyUrl);
script.src = script.src.replace('cdn.neonsky.app', 'storage.neonsky.app');
};
};
// Added script.onload from your original function
script.onload = function() {
if (window.currentLoadingGalleryId !== gallery.id) {
console.warn('Gallery ID mismatch! Expected:', gallery.id,
'Current:', window.currentLoadingGalleryId);
}
console.log(`Gallery script loaded for: ${gallery.title} (ID: ${gallery.id})`);
};
if (galleryContainer) {
setTimeout(() => {
galleryContainer.style.opacity = '1';
}, 50); // Small delay to ensure content is ready
}
console.log(`Loading fresh gallery script for: ${gallery.title}`);
document.body.appendChild(script);
}
// Function to ensure gallery container is visible
function ensureGalleryVisibility() {
const galleryContainer = document.querySelector('.gallery-container');
if (galleryContainer && galleryContainer.style.opacity === '0') {
console.log('ensureGalleryVisibility: Setting gallery container opacity to 1');
galleryContainer.style.opacity = '1';
}
}
// Handle browser back/forward navigation
window.addEventListener('popstate', async function(event) { // Make it async
if (window.__hydraPopstateLock) {
return;
}
window.__hydraPopstateLock = true;
try {
console.error('🔍 [Popstate] Event triggered. State:', event.state, 'Current URL:', window.location.pathname);
let galleryIdToLoad = null;
let galleryToLoad = null;
// First, try to get galleryId from event.state
if (event.state && event.state.galleryId) {
galleryIdToLoad = event.state.galleryId;
console.error('🔍 [Popstate] Found galleryId in event.state:', galleryIdToLoad);
// Ensure galleries array is available and use findGalleryById helper
const currentGalleries = window.galleries || galleries || [];
galleryToLoad = findGalleryById(currentGalleries, galleryIdToLoad);
}
if (window.__closingNestedLightbox) {
window.__closingNestedLightbox = false;
if (typeof updateActiveStates === 'function') updateActiveStates();
if (typeof updateMobileTitle === 'function') updateMobileTitle();
return;
}
if (window.lightboxNavigationStack?.length > 0 &&
typeof window.closeNestedGalleryLightbox === 'function') {
const stackTop = window.lightboxNavigationStack[window.lightboxNavigationStack.length - 1];
const parentId = stackTop?.parentGalleryId;
if (parentId && (event.state?.galleryId === parentId || galleryToLoad?.id === parentId)) {
window.closeNestedGalleryLightbox({ fromPopstate: true });
if (typeof updateActiveStates === 'function') updateActiveStates();
if (typeof updateMobileTitle === 'function') updateMobileTitle();
return;
}
}
// If no gallery found from state, resolve from URL slug (handles history entries with null state)
if (!galleryToLoad) {
console.error('🔍 [Popstate] No galleryId in event.state or gallery not found, reading from URL:', window.location.pathname);
const path = window.location.pathname.substring(1);
const isPreviewMode = window.location.hostname === 'preview.neonsky.app';
let slug = path;
let siteGuid = null;
if (!isPreviewMode && (path === '_manage' || path.startsWith('_manage/'))) {
slug = '';
}
if (isPreviewMode && path) {
const pathParts = path.split('/');
if (pathParts.length >= 1) siteGuid = pathParts[0];
if (pathParts.length >= 2) slug = pathParts[1];
else slug = '';
}
if (slug && typeof findGalleryByPath === 'function') {
galleryToLoad = findGalleryByPath(slug, isPreviewMode, siteGuid);
if (galleryToLoad) {
console.error('🔍 [Popstate] Resolved from URL slug: "' + galleryToLoad.title + '" (ID: ' + galleryToLoad.id + ')');
}
}
}
// If we have a gallery from state or URL, load it
let contentLoaded = false;
if (galleryToLoad) {
console.error('🔍 [Popstate] Found gallery in history: "' + galleryToLoad.title + '" (ID: ' + galleryToLoad.id + ')');
console.error('🔍 [Popstate] Gallery properties - isPage:', galleryToLoad.isPage, 'pageId:', galleryToLoad.pageId, 'has pageElements:', !!(galleryToLoad.pageElements && Array.isArray(galleryToLoad.pageElements)));
activeGalleryId = galleryToLoad.id;
window.activeGalleryId = galleryToLoad.id;
// Browser history navigation is explicit user intent — load content regardless of menu visibility.
// Determine if this is a page by checking multiple indicators
const isPage = galleryToLoad.isPage === true ||
(galleryToLoad.pageId && galleryToLoad.pageId.startsWith('page_')) ||
(galleryToLoad.pageElements && Array.isArray(galleryToLoad.pageElements) && galleryToLoad.pageElements.length > 0);
if (isPage) {
console.error('🔍 [Popstate] Detected as PAGE, loading page "' + galleryToLoad.title + '"');
const pageLoader = typeof window.loadPage === 'function' ? window.loadPage : loadPage;
if (typeof pageLoader === 'function') {
console.error('🔍 [Popstate] Calling loadPage for ID:', galleryToLoad.id);
pageLoader(galleryToLoad.id);
contentLoaded = true;
} else {
console.error('❌ [Popstate] loadPage function not available');
}
} else {
console.error('🔍 [Popstate] Detected as GALLERY, loading gallery "' + galleryToLoad.title + '"');
// Clear page state left over from prior page navigation
if (window.PageManager && typeof window.PageManager.clearCurrentPage === 'function') {
window.PageManager.clearCurrentPage();
}
window._activeContentType = 'gallery';
const galleryLoader = typeof window.loadGallery === 'function' ? window.loadGallery : loadGallery;
if (typeof galleryLoader === 'function') {
await galleryLoader(galleryToLoad.id, null, { forceReload: true });
contentLoaded = true;
} else {
console.error('❌ [Popstate] loadGallery function not available');
}
}
} else {
console.error('❌ [Popstate] No gallery found and handleURLNavigation not available');
}
// Update UI states after loading (or attempting to load) content
if (typeof updateActiveStates === 'function') updateActiveStates();
if (typeof updateMobileTitle === 'function') updateMobileTitle();
// Ensure gallery visibility after popstate navigation
setTimeout(ensureGalleryVisibility, 1000);
let currentLayout = 'sidebar';
if (window.MenuStyleCustomizer && window.MenuStyleCustomizer.settings && window.MenuStyleCustomizer.settings.menuLayout) {
currentLayout = window.MenuStyleCustomizer.settings.menuLayout;
} else if (document.body.classList.contains('menu-layout-horizontal')) {
currentLayout = 'horizontal';
}
if (currentLayout === 'horizontal') {
if (typeof updateActiveStatesHorizontal === 'function') {
console.log("Popstate: Calling updateActiveStatesHorizontal for horizontal layout with a delay.");
setTimeout(() => {
console.log("[Delayed Update from Popstate] Calling updateActiveStatesHorizontal.");
updateActiveStatesHorizontal();
}, 150); // Slightly longer delay for popstate, as full content might be re-initializing
}
}
} finally {
setTimeout(function() { window.__hydraPopstateLock = false; }, 300);
}
});
// Check for direct URL access on page load (fallback when bootstrap did not route)
document.addEventListener('DOMContentLoaded', function() {
setTimeout(function() {
console.log('Checking for deep links after DOM content loaded');
var __p = window.location.pathname;
if (__p !== '/' && __p !== '/_manage' && __p !== '/_manage/' && !window.__hydraBootstrapRouteAttempted) {
handleURLNavigation();
}
// Ensure gallery visibility after URL navigation
setTimeout(ensureGalleryVisibility, 1000);
}, 500);
});
document.addEventListener('page-save-requested', function(e) {
const data = e.detail;
if (data && data.pageId && data.elements) {
// Find the gallery/page that corresponds to this pageId
const page = galleries.find(g => g.pageId === data.pageId);
if (page) {
// Store the page elements
page.pageElements = data.elements;
// Save to server
saveGalleries();
}
}
});
document.addEventListener('DOMContentLoaded', function() {
function preloadRichTextEditor() {
if (window.RichTextEditor && typeof window.RichTextEditor._ensureEditorDependencies === 'function') {
console.log('Preloading Rich Text Editor dependencies');
return window.RichTextEditor._ensureEditorDependencies()
.then(() => console.log('Rich Text Editor dependencies preloaded successfully'))
.catch(error => console.warn('Failed to preload Rich Text Editor:', error));
}
if (window.PageManager && typeof window.PageManager._ensureRichTextEditor === 'function') {
return window.PageManager._ensureRichTextEditor()
.catch(error => console.warn('Failed to preload Rich Text Editor via PageManager:', error));
}
return null;
}
preloadRichTextEditor();
setTimeout(preloadRichTextEditor, 1000);
setTimeout(preloadRichTextEditor, 3000);
});
document.addEventListener('DOMContentLoaded', function() {
const submenuCheck = document.getElementById('createSubmenu');
const submenuTitleField = document.getElementById('submenuTitle');
const submenuTitleGroup = submenuTitleField?.parentElement;
if (submenuCheck && submenuTitleGroup) {
submenuCheck.addEventListener('change', function() {
submenuTitleGroup.style.display = this.checked ? 'block' : 'none';
});
}
});
// Also check immediately if document is already loaded (fallback only — avoid duplicate loadPage vs bootstrap)
if (document.readyState !== 'loading') {
setTimeout(function() {
console.log('Document already loaded, checking for deep links');
var __p2 = window.location.pathname;
if (__p2 !== '/' && __p2 !== '/_manage' && __p2 !== '/_manage/' && !window.__hydraBootstrapRouteAttempted) {
handleURLNavigation();
}
}, 300);
}
// ============================================
// CONCURRENT EDIT CONFLICT DETECTION
// ============================================
(function() {
// Initialize local timestamp tracking
window.lastKnownConfigTimestamp = null;
let conflictPollInterval = null;
// Initialize timestamp on page load
async function initializeConfigTimestamp() {
try {
const timestampUrl = typeof getApiUrl === 'function' ?
getApiUrl('/api/config-timestamp') :
'/api/config-timestamp';
console.error('[CONFLICT DETECT] Initializing config timestamp from:', timestampUrl);
const response = await fetch(timestampUrl);
if (response.ok) {
const data = await response.json();
if (data.lastModified) {
window.lastKnownConfigTimestamp = data.lastModified;
console.error('[CONFLICT DETECT] Initialized config timestamp:', window.lastKnownConfigTimestamp);
} else {
console.error('[CONFLICT DETECT] No lastModified in response:', data);
}
} else {
console.error('[CONFLICT DETECT] Failed to initialize timestamp, status:', response.status);
}
} catch (error) {
console.error('[CONFLICT DETECT] Could not initialize config timestamp:', error);
}
}
// Check for conflicts by polling server
async function checkForConflicts() {
// Only poll when in edit mode
if (!document.body.classList.contains('edit-mode-active')) {
console.error('[CONFLICT DETECT] Skipping poll - not in edit mode');
return;
}
try {
const timestampUrl = typeof getApiUrl === 'function' ?
getApiUrl('/api/config-timestamp') :
'/api/config-timestamp';
console.error('[CONFLICT DETECT] Polling for conflicts from:', timestampUrl);
console.error('[CONFLICT DETECT] Current local timestamp:', window.lastKnownConfigTimestamp);
const response = await fetch(timestampUrl);
if (response.ok) {
const data = await response.json();
const serverTimestamp = data.lastModified;
console.error('[CONFLICT DETECT] Server timestamp:', serverTimestamp);
// If we have a local timestamp and it differs from server, show conflict
if (window.lastKnownConfigTimestamp !== null &&
serverTimestamp !== null &&
serverTimestamp !== window.lastKnownConfigTimestamp) {
console.error('[CONFLICT DETECT] ⚠️ CONFLICT DETECTED! Local:', window.lastKnownConfigTimestamp, 'Server:', serverTimestamp);
showConflictIndicator();
} else {
console.error('[CONFLICT DETECT] No conflict - timestamps match or null');
hideConflictIndicator();
}
} else {
console.error('[CONFLICT DETECT] Poll failed with status:', response.status);
}
} catch (error) {
console.error('[CONFLICT DETECT] Error checking for conflicts:', error);
}
}
// Show conflict indicator icon
function showConflictIndicator() {
const indicator = document.getElementById('conflictIndicator');
console.error('[CONFLICT DETECT] Showing conflict indicator, element found:', !!indicator);
if (indicator) {
indicator.style.display = 'flex';
indicator.style.visibility = 'visible';
indicator.style.opacity = '1';
indicator.style.background = '#d32f2f';
indicator.style.borderColor = '#d32f2f';
console.error('[CONFLICT DETECT] Conflict indicator displayed');
} else {
console.error('[CONFLICT DETECT] ⚠️ Conflict indicator element not found in DOM!');
}
}
// Hide conflict indicator icon
function hideConflictIndicator() {
const indicator = document.getElementById('conflictIndicator');
if (indicator) {
indicator.style.display = 'none';
indicator.style.visibility = 'hidden';
indicator.style.opacity = '0';
}
}
// Start/stop polling based on edit mode
function updatePolling() {
const isEditMode = document.body.classList.contains('edit-mode-active');
// Always hide indicator when not in edit mode
if (!isEditMode) {
hideConflictIndicator();
}
if (isEditMode && !conflictPollInterval) {
// Start polling every 10 seconds
conflictPollInterval = setInterval(checkForConflicts, 10000);
console.error('[CONFLICT DETECT] ✅ Started conflict detection polling (every 10s)');
// Also check immediately
checkForConflicts();
} else if (!isEditMode && conflictPollInterval) {
// Stop polling when not in edit mode
clearInterval(conflictPollInterval);
conflictPollInterval = null;
hideConflictIndicator();
console.error('[CONFLICT DETECT] Stopped conflict detection polling');
}
}
// Initialize on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
initializeConfigTimestamp();
// Watch for edit mode changes
const editModeObserver = new MutationObserver(updatePolling);
editModeObserver.observe(document.body, {
attributes: true,
attributeFilter: ['class']
});
updatePolling();
});
} else {
initializeConfigTimestamp();
// Watch for edit mode changes
const editModeObserver = new MutationObserver(updatePolling);
editModeObserver.observe(document.body, {
attributes: true,
attributeFilter: ['class']
});
updatePolling();
}
})();
</script>
</head>
<body class="menu-layout-sidebar">
<a class="hydra-skip-link" href="#hydra-main-content">Skip to content</a>
<nav class="hydra-seo-nav" aria-label="Site navigation">
<ul>
<li><a href="/home">Contact</a></li>
</ul>
</nav>
<div class="global-header-controls">
<button id="editButton" class="btn-icon btn-primary" onclick="toggleEditMode()" style="display: none;" data-help-id="edit-toggle">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" color="currentColor">
<path d="m16.214 4.982l1.402-1.401a1.982 1.982 0 0 1 2.803 2.803l-1.401 1.402m-2.804-2.804l-5.234 5.234c-1.045 1.046-1.568 1.568-1.924 2.205S8.342 14.561 8 16c1.438-.342 2.942-.7 3.579-1.056s1.16-.879 2.205-1.924l5.234-5.234m-2.804-2.804l2.804 2.804"/>
<path d="M21 12c0 4.243 0 6.364-1.318 7.682S16.242 21 12 21s-6.364 0-7.682-1.318S3 16.242 3 12s0-6.364 1.318-7.682S7.758 3 12 3"/>
</g>
</svg>
</button>
<button id="conflictIndicator" class="btn-icon btn-primary" onclick="window.location.reload()" style="display: none;" data-custom-tooltip="Changes detected in another window" data-tooltip-position="right">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="color: white;">
<path fill="currentColor" d="M2 12c0 5 4 9 9 9c2.4 0 4.7-.9 6.4-2.6l-1.5-1.5c-1.3 1.4-3 2.1-4.9 2.1c-6.2 0-9.4-7.5-4.9-11.9S18 5.8 18 12h-3l4 4h.1l3.9-4h-3c0-5-4-9-9-9s-9 4-9 9m8 3h2v2h-2zm0-8h2v6h-2z"/>
</svg>
</button>
</div>
<div class="container">
<div class="mobile-header">
<div class="mobile-header-content">
<div class="horizontal-header-logo"></div>
<div class="horizontal-menu-container"></div>
</div>
<button id="hamburgerBtn" class="hamburger-btn" type="button" aria-label="Menu" aria-expanded="false" aria-controls="siteSidebar">
<span class="hamburger-icon" aria-hidden="true">
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
</span>
</button>
</div>
<div id="mobileBackdrop" class="mobile-backdrop"></div>
<div class="sidebar" id="siteSidebar" role="navigation" aria-label="Site">
<div class="sidebar-header">
</div>
<div class="edit-controls" data-help-id="edit-toolbar">
<button type="button" class="btn-icon btn-primary" onclick="openHydraHelp()" data-custom-tooltip="Help" data-help-id="help-toolbar">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.195 8.765A4 4 0 1 1 12 14v1m.05 4v.1h-.1V19z" />
</svg>
</button>
<button class="btn-icon btn-primary" onclick="toggleStyleEditor(undefined, event)" data-custom-tooltip="Edit Style" data-help-id="style-toolbar">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m19 12.13l-6.061 6.077c-1.783 1.788-2.675 2.682-3.77 2.78q-.27.025-.543 0c-1.094-.098-1.986-.992-3.769-2.78l-2.02-2.026a2.87 2.87 0 0 1 0-4.052m16.163 0l-8.082-8.103M19 12.129H2.837m8.081-8.103l-8.081 8.103m8.081-8.103L8.898 2M22 20a2 2 0 1 1-4 0c0-1.105 2-3 2-3s2 1.895 2 3" color="currentColor"/>
</svg>
</button>
<button class="btn-icon btn-primary" onclick="toggleAddForm()" data-custom-tooltip="Add Page">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" color="currentColor">
<path d="M13 2h.273c3.26 0 4.892 0 6.024.798c.324.228.612.5.855.805c.848 1.066.848 2.6.848 5.67v2.545c0 2.963 0 4.445-.469 5.628c-.754 1.903-2.348 3.403-4.37 4.113c-1.257.441-2.83.441-5.98.441c-1.798 0-2.698 0-3.416-.252c-1.155-.406-2.066-1.263-2.497-2.35C4 18.722 4 17.875 4 16.182V12"/>
<path d="M21 12a3.333 3.333 0 0 1-3.333 3.333c-.666 0-1.451-.116-2.098.057a1.67 1.67 0 0 0-1.179 1.179c-.173.647-.057 1.432-.057 2.098A3.333 3.333 0 0 1 11 22m0-16H3m4-4v8"/>
</g>
</svg>
</button>
<button class="btn-icon btn-primary" onclick="toggleSidebarElementForm()" data-custom-tooltip="Add Element">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M18 2v8m4-4h-8M2 6c0-1.4 0-2.1.272-2.635a2.5 2.5 0 0 1 1.093-1.093C3.9 2 4.6 2 6 2s2.1 0 2.635.272a2.5 2.5 0 0 1 1.093 1.093C10 3.9 10 4.6 10 6s0 2.1-.272 2.635a2.5 2.5 0 0 1-1.093 1.093C8.1 10 7.4 10 6 10s-2.1 0-2.635-.272a2.5 2.5 0 0 1-1.093-1.093C2 8.1 2 7.4 2 6m0 12c0-1.4 0-2.1.272-2.635a2.5 2.5 0 0 1 1.093-1.092C3.9 14 4.6 14 6 14s2.1 0 2.635.273a2.5 2.5 0 0 1 1.093 1.092C10 15.9 10 16.6 10 18s0 2.1-.272 2.635a2.5 2.5 0 0 1-1.093 1.092C8.1 22 7.4 22 6 22s-2.1 0-2.635-.273a2.5 2.5 0 0 1-1.093-1.092C2 20.1 2 19.4 2 18m12 0c0-1.4 0-2.1.273-2.635a2.5 2.5 0 0 1 1.092-1.092C15.9 14 16.6 14 18 14s2.1 0 2.635.273a2.5 2.5 0 0 1 1.092 1.092C22 15.9 22 16.6 22 18s0 2.1-.273 2.635a2.5 2.5 0 0 1-1.092 1.092C20.1 22 19.4 22 18 22s-2.1 0-2.635-.273a2.5 2.5 0 0 1-1.092-1.092C14 20.1 14 19.4 14 18" color="currentColor"/>
</svg>
</button>
<button class="btn-icon btn-primary" onclick="toggleMetadataEditor()" data-custom-tooltip="Site Metadata">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" color="currentColor"><path d="M7.5 4.945H16a1.5 1.5 0 0 1 1.5 1.5v1.5m-2.5 5H9m3 4H9"/><path d="M18.497 2H6.307c-.496 0-1.005.073-1.406.368c-1.274.935-2.256 3.02-.273 4.903c.556.528 1.334.72 2.099.72h11.557c.793 0 2.216.113 2.216 2.536v7.454c0 2.22-1.79 4.019-3.997 4.019h-9.03c-2.204 0-3.807-1.557-3.933-3.929L3.506 5.166"/></g></svg>
</button>
<button id="logoutButton" class="btn-icon btn-primary" onclick="logout()" data-custom-tooltip="Log Out" style="display: none;">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m11 3l-.663.234c-2.578.91-3.868 1.365-4.602 2.403S5 8.043 5 10.777v2.445c0 2.735 0 4.102.735 5.14c.734 1.039 2.024 1.494 4.602 2.404L11 21m10-9H11m10 0c0-.7-1.994-2.008-2.5-2.5M21 12c0 .7-1.994 2.008-2.5 2.5" color="currentColor"/>
</svg>
</button>
<script>
// Initialize mobile menu functionality
function initMobileMenu() {
const hamburgerBtn = document.getElementById('hamburgerBtn');
const sidebar = document.querySelector('.sidebar');
const mobileBackdrop = document.getElementById('mobileBackdrop');
// --- ADDED CHECK: Only update header if layout is NOT horizontal ---
let currentLayout = 'sidebar'; // Default
if (window.MenuStyleCustomizer && window.MenuStyleCustomizer.settings) {
currentLayout = window.MenuStyleCustomizer.settings.menuLayout || 'sidebar';
}
// --- END ADDED CHECK ---
// Ensure backdrop is properly hidden for horizontal layout on page load
if (mobileBackdrop && document.body.classList.contains('menu-layout-horizontal')) {
mobileBackdrop.classList.remove('mobile-open');
mobileBackdrop.classList.remove('active');
// Clear any inline overrides - CSS controls backdrop visibility via .mobile-open
mobileBackdrop.style.display = '';
mobileBackdrop.style.pointerEvents = '';
mobileBackdrop.style.opacity = '';
mobileBackdrop.style.visibility = '';
}
// Toggle mobile menu when hamburger is clicked
hamburgerBtn.addEventListener('click', function(e) {
e.stopPropagation();
toggleMobileMenu(); // Assumes this function exists
});
// Close mobile menu when backdrop is clicked
if (mobileBackdrop) {
mobileBackdrop.addEventListener('click', function() {
closeMobileMenu(); // Assumes this function exists
});
}
// Escape closes mobile menu (keyboard / SR)
document.addEventListener('keydown', function(e) {
if (e.key !== 'Escape') return;
const sb = document.querySelector('.sidebar');
if (sb && sb.classList.contains('mobile-open') && typeof closeMobileMenu === 'function') {
closeMobileMenu();
}
});
// Handle window resize events to ensure proper state
window.addEventListener('resize', function() {
// If we're not on mobile anymore, remove mobile-open class
if (window.innerWidth > 768 && sidebar.classList.contains('mobile-open')) {
closeMobileMenu(); // Assumes this function exists
}
});
}
document.addEventListener('keydown', function(e) {
// Check for caret (Shift+6)
if (e.key === '^' || (e.key === '6' && e.shiftKey)) {
// Only work in edit mode
if (window.isInEditMode && window.isInEditMode()) {
console.log('Import shortcut detected');
toggleImportClassicForm();
e.preventDefault();
}
}
});
// Enhanced tooltip management with automatic timeouts
// Store active tooltip timeouts by ID
window.tooltipTimeouts = {};
function showTooltip(trigger) {
const tooltipText = trigger.getAttribute('data-custom-tooltip');
if (!tooltipText) return;
// Get or create the main tooltip container
let container = document.getElementById('tooltip-container');
if (!container) {
console.log("Tooltip container not found, creating one.");
container = document.createElement('div'); // Assign to 'container'
container.id = 'tooltip-container';
// CSS for #tooltip-container should make it position:fixed, top:0, left:0, pointer-events:none, etc.
document.body.appendChild(container);
}
const tooltipId = `tooltip-${trigger.id || Math.random().toString(36).substr(2, 9)}`;
// Remove any existing tooltip for this trigger
const existingTooltip = document.getElementById(tooltipId);
if (existingTooltip) {
existingTooltip.remove();
}
// Clear any existing timeout for this tooltip
if (!window.tooltipTimeouts) {
window.tooltipTimeouts = {}; // Initialize if it doesn't exist
}
if (window.tooltipTimeouts[tooltipId]) {
clearTimeout(window.tooltipTimeouts[tooltipId]);
delete window.tooltipTimeouts[tooltipId];
}
// Create tooltip element
const tooltip = document.createElement('div');
tooltip.className = 'tooltip'; // Ensure your CSS styles .tooltip
tooltip.id = tooltipId;
tooltip.textContent = tooltipText;
// Add to container (now 'container' is guaranteed to be an element)
container.appendChild(tooltip);
// Position tooltip
if (typeof positionTooltip === 'function') {
positionTooltip(tooltip, trigger);
} else {
console.error("positionTooltip function is not defined.");
tooltip.remove(); // Clean up if we can't position
return;
}
// Show with animation (slight delay for CSS transition)
setTimeout(() => {
tooltip.style.opacity = '1';
// The transform for animation should be handled by CSS transitions
// e.g., .tooltip.visible { transform: translateX(-50%) translateY(-5px); }
// Or ensure positionTooltip sets final transform if it needs to override CSS.
// For now, assuming CSS handles the animated transform from its initial state.
}, 10);
trigger.setAttribute('data-active-tooltip', tooltipId);
// Set automatic timeout to hide after 3 seconds
window.tooltipTimeouts[tooltipId] = setTimeout(() => {
hideTooltip(trigger); // Ensure hideTooltip function exists
}, 3000);
// Also set up a global timer to ensure all tooltips are dismissed
if (window.globalTooltipTimer) {
clearTimeout(window.globalTooltipTimer);
}
window.globalTooltipTimer = setTimeout(() => {
// Force hide all visible tooltips after 3 seconds
const allTooltips = document.querySelectorAll('.tooltip');
allTooltips.forEach(tooltip => {
if (tooltip.style.opacity !== '0') {
tooltip.style.opacity = '0';
setTimeout(() => {
if (tooltip.parentNode) {
tooltip.parentNode.removeChild(tooltip);
}
}, 200);
}
});
// Clear all timeout references
if (window.tooltipTimeouts) {
for (const id in window.tooltipTimeouts) {
clearTimeout(window.tooltipTimeouts[id]);
}
window.tooltipTimeouts = {};
}
}, 3000);
}
// Function to hide a tooltip
function hideTooltip(trigger) {
const tooltipId = trigger.getAttribute('data-active-tooltip');
if (!tooltipId) return;
const tooltip = document.getElementById(tooltipId);
if (tooltip) {
// Fade out
tooltip.style.opacity = '0';
tooltip.style.transform = 'translateX(-50%) translateY(0)';
// Remove after animation
setTimeout(() => {
if (tooltip.parentNode) {
tooltip.parentNode.removeChild(tooltip);
}
}, 200);
}
// Clear any active timeout for this tooltip
if (window.tooltipTimeouts && window.tooltipTimeouts[tooltipId]) {
clearTimeout(window.tooltipTimeouts[tooltipId]);
delete window.tooltipTimeouts[tooltipId];
}
// Clear reference
trigger.removeAttribute('data-active-tooltip');
// If this was the last tooltip, clear the global timer
const remainingTooltips = document.querySelectorAll('.tooltip');
if (remainingTooltips.length === 0 && window.globalTooltipTimer) {
clearTimeout(window.globalTooltipTimer);
window.globalTooltipTimer = null;
}
}
// Function to initialize tooltips across the document
function initTooltipSystem() {
// Check if container already exists
let tooltipContainer = document.getElementById('tooltip-container');
// Create it if it doesn't exist
if (!tooltipContainer) {
tooltipContainer = document.createElement('div');
tooltipContainer.id = 'tooltip-container';
document.body.appendChild(tooltipContainer);
}
// Find all elements with data-custom-tooltip attribute
const tooltipTriggers = document.querySelectorAll('[data-custom-tooltip]');
// Add event listeners to each trigger
tooltipTriggers.forEach(trigger => {
// Skip if already initialized
if (trigger.hasAttribute('data-custom-tooltip-initialized')) return;
// Mark as initialized
trigger.setAttribute('data-custom-tooltip-initialized', 'true');
// MouseEnter: Show tooltip
trigger.addEventListener('mouseenter', () => {
showTooltip(trigger);
});
// MouseLeave: Hide tooltip
trigger.addEventListener('mouseleave', () => {
hideTooltip(trigger);
});
// Click: Hide tooltip immediately
trigger.addEventListener('click', () => {
hideTooltip(trigger);
});
// Also handle focus/blur for accessibility
trigger.addEventListener('focus', () => {
showTooltip(trigger);
});
trigger.addEventListener('blur', () => {
hideTooltip(trigger);
});
});
// Set up a failsafe function to clear any orphaned tooltips
window.clearAllTooltips = function() {
const container = document.getElementById('tooltip-container');
if (container) {
container.innerHTML = '';
}
// Clear all timeouts
if (window.tooltipTimeouts) {
for (const id in window.tooltipTimeouts) {
clearTimeout(window.tooltipTimeouts[id]);
}
window.tooltipTimeouts = {};
}
// Clear global timer
if (window.globalTooltipTimer) {
clearTimeout(window.globalTooltipTimer);
window.globalTooltipTimer = null;
}
// Clear all data-active-tooltip attributes
document.querySelectorAll('[data-active-tooltip]').forEach(el => {
el.removeAttribute('data-active-tooltip');
});
};
// Initialize new tooltips when DOM changes
setupMutationObserver();
// Add a global function to manually dismiss all tooltips (for testing)
window.dismissAllTooltips = function() {
const allTooltips = document.querySelectorAll('.tooltip');
allTooltips.forEach(tooltip => {
if (tooltip.style.opacity !== '0') {
tooltip.style.opacity = '0';
setTimeout(() => {
if (tooltip.parentNode) {
tooltip.parentNode.removeChild(tooltip);
}
}, 200);
}
});
// Clear all timeout references
if (window.tooltipTimeouts) {
for (const id in window.tooltipTimeouts) {
clearTimeout(window.tooltipTimeouts[id]);
}
window.tooltipTimeouts = {};
}
// Clear global timer
if (window.globalTooltipTimer) {
clearTimeout(window.globalTooltipTimer);
window.globalTooltipTimer = null;
}
// Clear all data-active-tooltip attributes
document.querySelectorAll('[data-active-tooltip]').forEach(el => {
el.removeAttribute('data-active-tooltip');
});
};
}
function positionTooltip(tooltip, trigger) {
const triggerRect = trigger.getBoundingClientRect(); // Viewport-relative coordinates
// To accurately get tooltip dimensions, temporarily make it visible but off-screen.
// The #tooltip-container is position:fixed, so the tooltip's absolute positioning
// will be relative to the viewport.
tooltip.style.visibility = 'hidden';
tooltip.style.display = 'block'; // Or 'inline-block' if that's its intended final display
// No need to change position to 'absolute' here if its CSS is already 'position: absolute;'
// within the 'fixed' #tooltip-container.
const tooltipHeight = tooltip.offsetHeight;
const tooltipWidth = tooltip.offsetWidth;
// Restore visibility; opacity transition will handle the fade-in.
tooltip.style.visibility = '';
// tooltip.style.display = ''; // Let CSS manage display if opacity is used for showing/hiding
// Check if tooltip should be positioned to the right (for conflict indicator)
const tooltipPosition = trigger.getAttribute('data-tooltip-position');
if (tooltipPosition === 'right') {
// Position to the right of the trigger
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let left = triggerRect.right + 10; // 10px gap from the right edge of trigger
let top = triggerRect.top + (triggerRect.height / 2) - (tooltipHeight / 2); // Vertically centered
// Recalculate tooltip dimensions after allowing text wrap
tooltip.style.maxWidth = '200px';
tooltip.style.whiteSpace = 'normal';
tooltip.style.wordWrap = 'break-word';
const tooltipWidthAfterWrap = tooltip.offsetWidth;
const tooltipHeightAfterWrap = tooltip.offsetHeight;
// If tooltip goes off-screen to the right, try positioning to the left of the button
if (left + tooltipWidthAfterWrap > viewportWidth - 5) {
left = triggerRect.left - tooltipWidthAfterWrap - 10; // Position to the left of button
// If it still goes off the left edge, position it at the left edge with padding
if (left < 5) {
left = 5; // Keep it at least 5px from the left edge
// The tooltip will wrap to fit within the available space
tooltip.style.maxWidth = `${viewportWidth - left - 10}px`;
}
} else {
// Tooltip fits on the right, but make sure it doesn't go off-screen
if (left + tooltipWidthAfterWrap > viewportWidth - 5) {
tooltip.style.maxWidth = `${viewportWidth - left - 10}px`;
}
}
// Recalculate after max-width adjustment
const finalTooltipWidth = tooltip.offsetWidth;
const finalTooltipHeight = tooltip.offsetHeight;
// Ensure tooltip doesn't go off-screen on the right side
if (left + finalTooltipWidth > viewportWidth - 5) {
left = viewportWidth - finalTooltipWidth - 5;
}
// Adjust if tooltip goes off-screen vertically
if (top < 5) {
top = 5;
} else if (top + finalTooltipHeight > viewportHeight - 5) {
top = viewportHeight - finalTooltipHeight - 5;
}
tooltip.style.left = `${left}px`;
tooltip.style.top = `${top}px`;
tooltip.classList.remove('tooltip-bottom'); // No arrow needed for side positioning
return;
}
// Calculate desired horizontal center point of the trigger
const triggerCenter = triggerRect.left + triggerRect.width / 2;
// Calculate tooltip's left position to center it under/over trigger's center
let left = triggerCenter - tooltipWidth / 2;
// Default position: above the trigger
// Arrow height (8px) + desired gap (e.g., 2px) = 10px total offset
let top = triggerRect.top - tooltipHeight - 10;
tooltip.classList.remove('tooltip-bottom'); // Manages arrow direction via CSS
// Check if it goes off-screen at the top (add a small buffer, e.g., 5px)
if (top < 5) {
// Position below the trigger
top = triggerRect.bottom + 10; // 10px gap (arrow height + small space)
tooltip.classList.add('tooltip-bottom');
}
// Boundary checks for left position to prevent going off-screen horizontally
const viewportWidth = window.innerWidth;
if (left < 5) { // 5px buffer from left edge
left = 5;
} else if (left + tooltipWidth > viewportWidth - 5) { // 5px buffer from right edge
left = viewportWidth - tooltipWidth - 5;
}
tooltip.style.left = `${left}px`;
tooltip.style.top = `${top}px`;
// The CSS for .tooltip might have `transform: translateX(-50%)` for initial centering.
// Since JS now calculates the exact 'left' for centering,
// we can clear any such transform that might interfere.
// However, if your CSS animation relies on a transform, adjust accordingly.
// If your CSS only uses opacity for transition, this is fine:
tooltip.style.transform = '';
}
// Watch for new tooltip triggers added to the DOM
function setupMutationObserver() {
// Check if MutationObserver is available
if (!window.MutationObserver) return;
// Create observer instance
const observer = new MutationObserver((mutations) => {
let shouldInit = false;
// Check if relevant nodes were added
mutations.forEach(mutation => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(node => {
// Check if it's an Element and has data-custom-tooltip or contains elements with data-custom-tooltip
if (node.nodeType === 1) { // ELEMENT_NODE
if (node.hasAttribute && node.hasAttribute('data-custom-tooltip')) {
shouldInit = true;
} else if (node.querySelectorAll) {
const tooltips = node.querySelectorAll('[data-custom-tooltip]');
if (tooltips.length) shouldInit = true;
}
}
});
}
});
// If relevant nodes found, reinitialize tooltips
if (shouldInit) {
initTooltipSystem();
}
});
// Start observing the entire document with the configured parameters
observer.observe(document.body, {
childList: true,
subtree: true
});
}
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', initTooltipSystem);
// Also initialize immediately if document already loaded
if (document.readyState === 'complete' || document.readyState === 'interactive') {
initTooltipSystem();
}
// Add a global safety function that runs every minute to clear any tooltips that might have gotten stuck
setInterval(() => {
// Get all visible tooltips
const visibleTooltips = document.querySelectorAll('.tooltip[style*="opacity: 1"]');
// If any are found, check how long they've been visible
if (visibleTooltips.length > 0) {
console.log('Found visible tooltips during cleanup check, clearing them');
window.clearAllTooltips();
}
}, 5000); //
// Mobile drawer a11y: Tab cycle + restore focus. No visual/mouse behavior changes.
var _mobileMenuFocusTrapHandler = null;
function getMobileMenuFocusables(sidebar) {
if (!sidebar) return [];
return Array.prototype.filter.call(
sidebar.querySelectorAll('a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'),
function (el) {
if (el.getAttribute('aria-hidden') === 'true') return false;
var style = typeof getComputedStyle === 'function' ? getComputedStyle(el) : null;
if (style && (style.display === 'none' || style.visibility === 'hidden')) return false;
return el.offsetWidth > 0 || el.offsetHeight > 0 || el === document.activeElement;
}
);
}
function releaseMobileMenuFocusTrap() {
if (_mobileMenuFocusTrapHandler) {
document.removeEventListener('keydown', _mobileMenuFocusTrapHandler, true);
_mobileMenuFocusTrapHandler = null;
}
}
function installMobileMenuFocusTrap(sidebar) {
releaseMobileMenuFocusTrap();
if (!sidebar) return;
_mobileMenuFocusTrapHandler = function (event) {
if (event.key !== 'Tab') return;
var sb = document.querySelector('.sidebar');
if (!sb || !sb.classList.contains('mobile-open')) return;
var focusables = getMobileMenuFocusables(sb);
var hamburger = document.getElementById('hamburgerBtn');
// Include hamburger so Shift+Tab from first item returns to the control that opened the drawer
var cycle = hamburger ? [hamburger].concat(focusables.filter(function (el) { return el !== hamburger; })) : focusables;
if (!cycle.length) return;
var first = cycle[0];
var last = cycle[cycle.length - 1];
if (event.shiftKey) {
if (document.activeElement === first || (sb.contains(document.activeElement) === false && document.activeElement !== hamburger)) {
event.preventDefault();
last.focus();
}
} else if (document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', _mobileMenuFocusTrapHandler, true);
}
// Toggle mobile menu state
function toggleMobileMenu() {
const sidebar = document.querySelector('.sidebar');
const hamburgerBtn = document.getElementById('hamburgerBtn');
const mobileBackdrop = document.getElementById('mobileBackdrop');
// Check if we're in horizontal layout mode
const isHorizontalLayout = document.body.classList.contains('menu-layout-horizontal');
sidebar.classList.toggle('mobile-open');
hamburgerBtn.classList.toggle('active');
const isOpen = sidebar.classList.contains('mobile-open');
hamburgerBtn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
if (mobileBackdrop) {
if (isHorizontalLayout) {
// For horizontal layout on mobile, use normal backdrop (tap outside to close)
// Clear any inline overrides so CSS can control visibility
mobileBackdrop.style.display = '';
mobileBackdrop.style.pointerEvents = '';
mobileBackdrop.style.opacity = '';
mobileBackdrop.style.visibility = '';
mobileBackdrop.classList.toggle('mobile-open', isOpen);
setTimeout(() => {
mobileBackdrop.classList.toggle('active', isOpen);
}, 10);
} else {
// For other layouts, use normal backdrop behavior
mobileBackdrop.classList.toggle('mobile-open');
// Slight delay to show opacity transition
setTimeout(() => {
mobileBackdrop.classList.toggle('active', isOpen);
}, 10);
}
}
// Prevent body scrolling when menu is open
document.body.style.overflow = isOpen ? 'hidden' : '';
if (isOpen) {
// Trap Tab only — do not steal focus on mouse/touch open (avoids focus rings / scroll jumps)
installMobileMenuFocusTrap(sidebar);
} else {
releaseMobileMenuFocusTrap();
// Restore focus only if keyboard focus was inside the drawer
if (hamburgerBtn && sidebar && sidebar.contains(document.activeElement)) {
try { hamburgerBtn.focus(); } catch (e) {}
}
}
}
// Close the mobile menu
function closeMobileMenu() {
const sidebar = document.querySelector('.sidebar');
const hamburgerBtn = document.getElementById('hamburgerBtn');
const mobileBackdrop = document.getElementById('mobileBackdrop');
const wasOpen = sidebar && sidebar.classList.contains('mobile-open');
// Check if we're in horizontal layout mode
const isHorizontalLayout = document.body.classList.contains('menu-layout-horizontal');
sidebar.classList.remove('mobile-open');
hamburgerBtn.classList.remove('active');
if (hamburgerBtn) hamburgerBtn.setAttribute('aria-expanded', 'false');
if (mobileBackdrop) {
if (isHorizontalLayout) {
// For horizontal layout, clear inline overrides and remove mobile-open
mobileBackdrop.style.display = '';
mobileBackdrop.style.pointerEvents = '';
mobileBackdrop.style.opacity = '';
mobileBackdrop.style.visibility = '';
mobileBackdrop.classList.remove('mobile-open');
mobileBackdrop.classList.remove('active');
} else {
// For other layouts, use normal backdrop behavior
mobileBackdrop.classList.remove('active');
setTimeout(() => {
mobileBackdrop.classList.remove('mobile-open');
}, 300); // Match transition duration
}
}
// Re-enable body scrolling
document.body.style.overflow = '';
releaseMobileMenuFocusTrap();
// Return focus to hamburger only if focus was inside the drawer (keyboard/SR); mouse users unchanged
if (wasOpen && hamburgerBtn && sidebar && sidebar.contains(document.activeElement)) {
try { hamburgerBtn.focus(); } catch (e) {}
}
}
function updateMobileHeaderContent() {
const currentLayout = typeof getEffectiveMenuLayout === 'function'
? getEffectiveMenuLayout()
: (window.MenuStyleCustomizer && window.MenuStyleCustomizer.settings
? window.MenuStyleCustomizer.settings.menuLayout || 'sidebar'
: (document.body.classList.contains('menu-layout-horizontal') ? 'horizontal' : 'sidebar'));
const isMobileView = window.innerWidth <= 768; // Assuming 768px is your mobile breakpoint
const mobileHeaderContent = document.querySelector('.mobile-header-content');
if (!mobileHeaderContent) {
console.warn("updateMobileHeaderContent: .mobile-header-content element not found.");
return;
}
// On mobile, center the content unless Space Colony (left-aligned title)
if (isMobileView) {
const isSpaceColonyTheme = typeof isSpaceColonySiteTheme === 'function' && isSpaceColonySiteTheme();
mobileHeaderContent.style.justifyContent = isSpaceColonyTheme ? 'flex-start' : 'center';
// Also ensure logo-menu-container doesn't grow
const logoMenuContainer = mobileHeaderContent.querySelector('.logo-menu-container');
if (logoMenuContainer) {
logoMenuContainer.style.flexGrow = '0';
logoMenuContainer.style.justifyContent = isSpaceColonyTheme ? 'flex-start' : 'center';
}
}
console.log(`updateMobileHeaderContent: Layout: ${currentLayout}, Mobile: ${isMobileView}`);
// Clear previous generic mobile content (like centered logo/title)
const existingMobileImage = mobileHeaderContent.querySelector('.mobile-header-image');
const existingMobileText = mobileHeaderContent.querySelector('.mobile-header-text, .mobile-header-menu-title, .mobile-header-title');
if (existingMobileImage) {
// The img is always appended directly (never wrapped in an <a>). Remove it straight.
// Also handle any legacy <a>-wrapped images left from older deployments.
const imageParent = existingMobileImage.parentElement;
if (imageParent && imageParent.tagName === 'A' && imageParent.parentElement === mobileHeaderContent) {
imageParent.remove();
} else {
existingMobileImage.remove();
}
}
// Sweep any stray empty <a> tags left by older deployments that used the wrapper approach
Array.from(mobileHeaderContent.children).forEach(function(child) {
if (child.tagName === 'A' && !child.querySelector('.mobile-header-image') && !(child.textContent || '').trim()) {
child.remove();
}
});
if (existingMobileText) existingMobileText.remove();
if (currentLayout === 'horizontal' && !isMobileView) {
// Desktop Horizontal Layout:
// The logo is handled by renderHorizontalMenu placing it in .horizontal-header-logo.
// The menu items are in .horizontal-menu-container.
// Remove stray mobile-only title/image nodes (duplicate of horizontal-header-logo).
mobileHeaderContent.querySelectorAll('.mobile-header-text, .mobile-header-menu-title, .mobile-header-title, .mobile-header-image').forEach(function(el) {
el.remove();
});
console.log("updateMobileHeaderContent: Desktop horizontal layout, re-rendering horizontal menu to ensure sidebar elements are available.");
if (typeof renderHorizontalMenu === 'function') {
// Wait for sidebar elements to be available before rendering
const waitForSidebarElements = (attempts = 0) => {
const maxAttempts = 10;
if (window.SidebarManager && window.SidebarManager.elements && window.SidebarManager.elements.length > 0) {
console.log("updateMobileHeaderContent: Sidebar elements available, rendering horizontal menu.");
renderHorizontalMenu();
} else if (attempts < maxAttempts) {
const attemptNum = attempts + 1;
console.log("updateMobileHeaderContent: Waiting for sidebar elements (attempt " + attemptNum + "/" + maxAttempts + ")...");
setTimeout(() => waitForSidebarElements(attempts + 1), 100);
} else {
console.warn("updateMobileHeaderContent: Sidebar elements not available after waiting, rendering anyway.");
renderHorizontalMenu();
}
};
waitForSidebarElements();
}
return;
}
// For Horizontal layout on mobile, we need to clear the horizontal menu items
// and show only the mobile header logo/title
if (currentLayout === 'horizontal' && isMobileView) {
console.log("updateMobileHeaderContent: Mobile horizontal layout - clearing horizontal menu items and showing mobile logo.");
// Clear horizontal menu items from mobile header
const horizontalMenuContainer = mobileHeaderContent.querySelector('.horizontal-menu-container');
if (horizontalMenuContainer) {
console.log("updateMobileHeaderContent: Clearing horizontal menu container");
horizontalMenuContainer.innerHTML = '';
} else {
console.log("updateMobileHeaderContent: No horizontal menu container found to clear");
}
// Clear horizontal header logo from mobile header
const horizontalHeaderLogo = mobileHeaderContent.querySelector('.horizontal-header-logo');
if (horizontalHeaderLogo) {
console.log("updateMobileHeaderContent: Clearing horizontal header logo");
horizontalHeaderLogo.innerHTML = '';
} else {
console.log("updateMobileHeaderContent: No horizontal header logo found to clear");
}
// Also clear any existing horizontal menu items that might be directly in mobile-header-content
const existingHorizontalMenuItems = mobileHeaderContent.querySelectorAll('.horizontal-menu-item');
if (existingHorizontalMenuItems.length > 0) {
console.log('updateMobileHeaderContent: Removing ' + existingHorizontalMenuItems.length + ' existing horizontal menu items');
existingHorizontalMenuItems.forEach(item => item.remove());
}
}
// For Sidebar, Top, OR Horizontal layout on mobile:
// Populate .mobile-header-content with the first sidebar element (logo/title).
// The CSS will hide .horizontal-menu-container and .horizontal-header-logo on mobile for horizontal layout.
console.log("updateMobileHeaderContent: Populating with first sidebar element.");
// For horizontal layout on mobile, use the header-specific function to get logo/title
// For other layouts, use the general function
let firstElement;
if (currentLayout === 'horizontal' && isMobileView) {
firstElement = getFirstSidebarElementForHeader(); // Gets logo/title specifically
console.log("updateMobileHeaderContent: Using getFirstSidebarElementForHeader for mobile horizontal layout");
} else {
firstElement = getFirstSidebarElement(); // Gets any first element
console.log("updateMobileHeaderContent: Using getFirstSidebarElement for other layouts");
}
if (firstElement) {
console.log('updateMobileHeaderContent: First element found:', JSON.stringify(firstElement));
if (firstElement.type === 'image' && (firstElement.imageData || firstElement.imageUrl)) {
console.log('updateMobileHeaderContent: Creating mobile header image');
const img = document.createElement('img');
img.src = firstElement.imageUrl || firstElement.imageData;
img.alt = resolveBrandLogoAlt(firstElement);
img.className = 'mobile-header-image sidebar-image'; // For specific styling if needed
let mobileDisplayNode = img;
if (firstElement.colorizeWithSiteColors &&
window.SidebarManager &&
typeof window.SidebarManager._buildSidebarImageDisplayNode === 'function') {
const buildData = Object.assign({}, firstElement);
if (!buildData.imageWidth) {
buildData.imageWidth = 'auto';
}
mobileDisplayNode = window.SidebarManager._buildSidebarImageDisplayNode(img, buildData);
}
// Opt-in per-image padding wrapper (no-op when unset)
if (window.SidebarManager &&
typeof window.SidebarManager._wrapWithImagePadding === 'function') {
mobileDisplayNode = window.SidebarManager._wrapWithImagePadding(mobileDisplayNode, firstElement);
}
// Handle linking: always append the img directly so existing CSS centering is
// not disrupted by an <a> wrapper (which the many !important positioning rules
// were not written to handle). Navigation is wired via a click handler.
if (firstElement.linkType && firstElement.linkType !== 'none') {
let finalLinkHref = '#';
let isValidLink = false;
const linkTarget = firstElement.linkTarget || '_self';
if (firstElement.linkType === 'external' && firstElement.linkUrl) {
finalLinkHref = firstElement.linkUrl;
isValidLink = true;
} else if (firstElement.linkType === 'internal' && firstElement.linkPageId) {
const allGalleries = window.galleries || (typeof galleries !== 'undefined' ? galleries : []);
const targetPage = allGalleries.find(g => g.id === parseInt(firstElement.linkPageId));
if (targetPage) {
const isPreviewMode = window.location.hostname === 'preview.neonsky.app';
if (isPreviewMode) {
const pathParts = window.location.pathname.split('/').filter(Boolean);
const siteGuid = pathParts[0];
if (siteGuid && targetPage.slug) {
finalLinkHref = '/' + siteGuid + '/' + targetPage.slug;
isValidLink = true;
} else if (targetPage.url && targetPage.url.startsWith('/')) {
finalLinkHref = targetPage.url;
isValidLink = true;
}
} else {
if (targetPage.slug) {
finalLinkHref = '/' + targetPage.slug;
isValidLink = true;
} else if (targetPage.url && targetPage.url.startsWith('/')) {
finalLinkHref = targetPage.url;
isValidLink = true;
}
}
}
}
if (isValidLink) {
mobileDisplayNode.style.cursor = 'pointer';
const capturedHref = finalLinkHref;
const capturedTarget = linkTarget;
const capturedLinkType = firstElement.linkType;
const capturedPageId = firstElement.linkPageId;
mobileDisplayNode.addEventListener('click', (e) => {
e.preventDefault();
if (capturedLinkType === 'internal' && capturedPageId) {
const allGalleries = window.galleries || (typeof galleries !== 'undefined' ? galleries : []);
const targetPage = allGalleries.find(g => g.id === parseInt(capturedPageId));
if (targetPage) {
if (isPageMenuItem(targetPage) && typeof window.loadPage === 'function') {
window.loadPage(targetPage.id);
} else if (typeof window.loadGallery === 'function') {
window.loadGallery(targetPage.id);
} else {
window.location.href = capturedHref;
}
} else {
window.location.href = capturedHref;
}
} else {
if (capturedTarget === '_blank') {
window.open(capturedHref, '_blank');
} else {
window.location.href = capturedHref;
}
}
});
}
}
mobileHeaderContent.appendChild(mobileDisplayNode);
} else if (firstElement.type === 'text' && firstElement.textContent) {
console.log('updateMobileHeaderContent: Creating mobile header text');
const textDiv = document.createElement('div');
textDiv.className = 'mobile-header-text'; // For specific styling
textDiv.innerHTML = firstElement.textContent; // Use innerHTML if textContent can contain HTML
const titleEl = textDiv.querySelector('.site-title-text, h1, h2, p:last-of-type, p:only-of-type');
if (titleEl && !titleEl.classList.contains('site-title-text')) {
titleEl.classList.add('site-title-text');
}
mobileHeaderContent.appendChild(textDiv);
} else if (firstElement.type === 'menu' && galleries && galleries.length > 0) {
console.log('updateMobileHeaderContent: Creating mobile header menu title');
// Fallback for 'menu' type, find the first visible gallery title
const firstVisibleGallery = galleries.find(g => g.visible !== false && !g.isSpacer);
const titleText = firstVisibleGallery ? firstVisibleGallery.title : (firstElement.title || 'Menu');
const titleDiv = document.createElement('div');
titleDiv.className = 'mobile-header-menu-title'; // For specific styling
titleDiv.textContent = titleText;
mobileHeaderContent.appendChild(titleDiv);
} else { // Default fallback
console.log('updateMobileHeaderContent: Creating mobile header title (fallback)');
const titleDiv = document.createElement('div');
titleDiv.className = 'mobile-header-title'; // For specific styling
titleDiv.textContent = firstElement.title || (galleries && galleries.length > 0 && galleries[0].title) || 'Menu';
mobileHeaderContent.appendChild(titleDiv);
}
} else {
// Fallback if no sidebar elements exist
const defaultTitle = document.createElement('div');
defaultTitle.className = 'mobile-header-title';
defaultTitle.textContent = 'Menu';
mobileHeaderContent.appendChild(defaultTitle);
console.log("updateMobileHeaderContent: No first sidebar element found, showing default title.");
}
if (typeof syncContentMarginToHorizontalHeader === 'function') {
syncContentMarginToHorizontalHeader();
}
}
// Helper function to get the first element in the sidebar
function getFirstSidebarElement() {
// Check if window.SidebarManager exists and has elements
if (window.SidebarManager && window.SidebarManager.elements && window.SidebarManager.elements.length > 0) {
// Filter to only visible elements
const visibleElements = window.SidebarManager.elements.filter(el => el.visible !== false);
if (visibleElements.length > 0) {
// Sort by position and return the first one
return visibleElements.sort((a, b) => a.position - b.position)[0];
}
}
// If no sidebar elements, return first gallery as fallback
if (galleries.length > 0) {
const firstVisibleGallery = galleries.find(g => g.visible !== false);
if (firstVisibleGallery) {
return {
type: 'menu',
title: firstVisibleGallery.title
};
}
}
// Default fallback
return {
type: 'text',
title: 'Menu'
};
}
// KEEP YOUR ORIGINAL initMobileMenu, toggleMobileMenu, closeMobileMenu FUNCTIONS EXACTLY AS THEY ARE
// Just replace the updateMobileTitle function and add our initialization code
// Replace the existing updateMobileTitle function with this one
function updateMobileTitle() {
// Always call our implementation that shows the top sidebar element
updateMobileHeaderContent();
}
// Add window resize listener to handle mobile header content updates
window.addEventListener('resize', () => {
// Debounce the resize event to avoid excessive calls
clearTimeout(window.resizeTimeout);
window.resizeTimeout = setTimeout(() => {
if (typeof updateMobileHeaderContent === 'function') {
updateMobileHeaderContent();
}
if (typeof applyHorizontalLogoMenuLayoutStyles === 'function') {
applyHorizontalLogoMenuLayoutStyles();
}
if (typeof syncContentMarginToHorizontalHeader === 'function') {
syncContentMarginToHorizontalHeader();
}
}, 250);
});
//separate initialization function that doesn't replace the original initMobileMenu
function initTopElementDisplay() {
console.log("Initializing top element display");
// Run immediately
updateMobileHeaderContent();
if (typeof syncContentMarginToHorizontalHeader === 'function') {
setTimeout(syncContentMarginToHorizontalHeader, 150);
}
// Also hook into loadGallery to ensure it runs whenever galleries change
const originalLoadGallery = window.loadGallery;
if (typeof originalLoadGallery === 'function') {
window.loadGallery = function(id, event, options) {
// Forward all args (including post-save { forceReload: true }) to the underlying loader
const result = originalLoadGallery(id, event, options);
updateMobileHeaderContent();
return result;
};
}
}
// Initialize our functionality after a short delay to ensure SidebarManager is ready
document.addEventListener('DOMContentLoaded', function() {
// Short delay to ensure SidebarManager is initialized
setTimeout(initTopElementDisplay, 100);
// Also run a bit later to catch any late-loading elements
setTimeout(updateMobileHeaderContent, 500);
});
// Run immediately if document is already loaded
if (document.readyState === 'complete' || document.readyState === 'interactive') {
setTimeout(initTopElementDisplay, 100);
}
// Add a final fallback to ensure gallery visibility on window load
window.addEventListener('load', function() {
setTimeout(ensureGalleryVisibility, 2000);
});
(function() {
/**
* EnhancedGallerySystem - Comprehensive gallery script and state management
*/
class EnhancedGallerySystem {
constructor() {
// Core state tracking
this.initialized = false;
this.initPromise = null;
this.isLoading = false;
this.currentGalleryId = null;
this.version = '260814-001'; // Increment when gallery scripts change (matches current versioned filenames)
// Track loaded resources
this.loadedScripts = new Set();
this.loadedStyles = new Set();
// Resources to ensure are loaded - Use Worker route (avoids CDN SSL issues)
const galleryScriptsBase = window.location.origin + '/gallery-scripts/';
this.essentialResources = {
scripts: [
{ id: 'neon-gallery-main', src: galleryScriptsBase + 'neon-gallery-main-v260814-001.js' },
{ id: 'neon-gallery-hp', src: galleryScriptsBase + 'neon-gallery-hp-hydra-v260814-001.js' }
],
styles: [
{ id: 'neon-gallery-css', href: galleryScriptsBase + 'neon-gallery-hp-hydra-v260814-001.css' }
]
};
// Keep track of original initialization functions
this.originalInitFunctions = {};
// Keep reference to gallery state between changes
this.previousGalleryState = null;
// Track last initialization time per gallery ID to prevent duplicates
this.lastInitializationTime = new Map();
this._loadInProgress = null;
this._loadingGalleryId = null;
}
/**
* Setup the correct gallery container structure
* @param {HTMLElement} container - The main container element
* @returns {boolean} - Success indicator
*/
setupCorrectGalleryContainer(container) {
console.log('EnhancedGallerySystem: Setting up correct gallery container structure');
// Validate container
if (!container) {
console.error('EnhancedGallerySystem: Main gallery container is null');
return false;
}
try {
// Clear the container
container.innerHTML = '';
// Create the exact container structure expected by the gallery script
const neonContainer = document.createElement('div');
neonContainer.id = 'neon-gallery-container';
neonContainer.style.width = '100%';
neonContainer.style.height = '100%';
neonContainer.style.position = 'relative';
// Add the container to the DOM
container.appendChild(neonContainer);
// Set visibility
container.style.opacity = '1';
container.style.display = 'block';
console.log('EnhancedGallerySystem: Gallery container structure created');
return true;
} catch (error) {
console.error('EnhancedGallerySystem: Error setting up gallery container:', error);
return false;
}
}
/**
* Ensure #neon-gallery-container exists before gallery scripts execute.
* neon-gallery-main bails early without it; hp may leave neonGalleryUtils as {}.
*/
ensureGalleryContainerPlaceholder(container) {
if (document.getElementById('neon-gallery-container')) {
return document.getElementById('neon-gallery-container');
}
const host = container || document.querySelector('.gallery-container') || document.getElementById('galleryFrame')?.parentElement;
if (!host) {
return null;
}
const neonContainer = document.createElement('div');
neonContainer.id = 'neon-gallery-container';
neonContainer.className = 'gallery-direct-content';
neonContainer.style.width = '100%';
neonContainer.style.height = '100%';
neonContainer.style.position = 'relative';
host.appendChild(neonContainer);
console.log('EnhancedGallerySystem: Created placeholder neon-gallery-container before script load');
return neonContainer;
}
/**
* Wait for neon-gallery-main to expose loadGallerySettings; reload main if needed.
*/
async ensureNeonGalleryUtilsReady(container) {
const hasLoadGallerySettings = () =>
typeof window.neonGalleryUtils?.loadGallerySettings === 'function';
if (hasLoadGallerySettings()) {
return true;
}
const mainScript = this.essentialResources.scripts.find(s => s.id === 'neon-gallery-main');
if (!mainScript) {
return false;
}
console.warn('EnhancedGallerySystem: neonGalleryUtils.loadGallerySettings missing — reloading main script');
this.ensureGalleryContainerPlaceholder(container);
document.querySelectorAll('script[src*="neon-gallery-main"]').forEach(el => {
if (el.parentNode) el.parentNode.removeChild(el);
});
this.loadedScripts.delete('neon-gallery-main');
try { delete window.__neonGalleryMainBootstrapWipeDone; } catch (e) { window.__neonGalleryMainBootstrapWipeDone = false; }
await new Promise((resolve, reject) => {
const scriptElement = document.createElement('script');
scriptElement.id = 'neon-gallery-main';
scriptElement.src = `${mainScript.src}?v=${this.version}&reload=${Date.now()}`;
scriptElement.async = false;
scriptElement.onload = () => {
this.loadedScripts.add('neon-gallery-main');
resolve();
};
scriptElement.onerror = (err) => reject(err);
document.body.appendChild(scriptElement);
});
for (let i = 0; i < 50; i++) {
if (hasLoadGallerySettings()) {
return true;
}
await new Promise(r => setTimeout(r, 100));
}
return hasLoadGallerySettings();
}
/**
* Preload styles only — defer script loading until a gallery load provides a container.
*/
preloadStylesOnly() {
return this.loadStyles();
}
/**
* Reset initialization tracking when gallery scripts are removed from the DOM
* (e.g. loadPage cleanup). Without this, a pending initPromise never resolves and
* subsequent loadGallery calls hang with isLoading:true, initialized:false.
*/
resetInitializationState() {
console.log('EnhancedGallerySystem: Resetting initialization state');
this.initPromise = null;
this.initialized = false;
this.isLoading = false;
this.loadedScripts.clear();
this.loadedStyles.clear();
// Allow neon-gallery-main bootstrap wipe once again after scripts are re-injected
try { delete window.__neonGalleryMainBootstrapWipeDone; } catch (e) { window.__neonGalleryMainBootstrapWipeDone = false; }
}
/**
* Initialize the gallery system
* @returns {Promise} Resolves when all core dependencies are loaded
*/
initialize() {
// Return existing promise if already initializing or complete
if (this.initPromise) {
return this.initPromise;
}
console.log('EnhancedGallerySystem: Initializing gallery framework');
this.isLoading = true;
// Create a promise to track loading completion
this.initPromise = (async () => {
try {
// CRITICAL: Initialize neonGalleryState before anything else
// This prevents "Cannot set properties of undefined" errors
if (!window.neonGalleryState) {
window.neonGalleryState = {
initialized: false,
initializing: false,
domInitialized: false,
configApplied: false,
initializationPromise: null
};
console.log('EnhancedGallerySystem: Initialized neonGalleryState');
}
// 1. First ensure all essential styles are loaded
await this.loadStyles();
// 2. Preload all scripts to hint to the browser
this.preloadScripts();
// 3. Ensure gallery container exists before scripts that require it
this.ensureGalleryContainerPlaceholder(
document.querySelector('.gallery-container')
);
// 4. Load all essential scripts
await this.loadScripts();
// 5. Initialize the gallery events
this.setupEvents();
// Mark as fully initialized
this.initialized = true;
this.isLoading = false;
console.log('EnhancedGallerySystem: Successfully initialized');
return true;
} catch (error) {
console.error('EnhancedGallerySystem: Initialization failed:', error);
this.isLoading = false;
throw error;
}
})();
return this.initPromise;
}
/**
* Load all necessary stylesheet resources
*/
async loadStyles() {
console.log('EnhancedGallerySystem: Loading essential stylesheets');
const stylePromises = this.essentialResources.styles.map(style => {
// Skip if already loaded
if (this.loadedStyles.has(style.id) || document.getElementById(style.id)) {
console.log(`Style already loaded: ${style.id}`);
this.loadedStyles.add(style.id);
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const link = document.createElement('link');
link.id = style.id;
link.rel = 'stylesheet';
// Use proxy URL if flag is set, otherwise use CDN URL
let styleUrl = `${style.href}?v=${this.version}`;
if (window.useCdnProxy && window.convertCdnToProxyUrl && style.href.includes('cdn.neonsky.app')) {
styleUrl = window.convertCdnToProxyUrl(style.href) + '?v=' + this.version;
console.log('🔍 Using proxy URL for style:', style.id, styleUrl);
}
link.href = styleUrl;
link.onload = () => {
console.log(`✅ Style loaded: ${style.id}`);
this.loadedStyles.add(style.id);
resolve();
};
link.onerror = (err) => {
console.error(`❌ Failed to load style: ${style.id}`, err);
if (style.href.includes('cdn.neonsky.app') && !styleUrl.includes('/cdn-proxy/')) {
console.error('🔄 Retrying via proxy:', style.href);
const proxyUrl = window.convertCdnToProxyUrl ? window.convertCdnToProxyUrl(style.href) : style.href.replace('https://cdn.neonsky.app/', window.location.origin + '/cdn-proxy/');
link.href = proxyUrl + '?v=' + this.version;
link.onerror = function() {
console.error('❌ Proxy failed, trying storage:', proxyUrl);
link.href = link.href.replace('cdn.neonsky.app', 'storage.neonsky.app');
// Don't reject - let it try storage
};
} else {
reject(new Error(`Failed to load ${style.id}`));
}
};
document.head.appendChild(link);
});
});
// Wait for all styles to load
await Promise.all(stylePromises);
console.log('EnhancedGallerySystem: All stylesheets loaded');
}
/**
* Preload scripts to improve browser loading priority
*/
preloadScripts() {
this.essentialResources.scripts.forEach(script => {
// Enhanced check for already preloaded scripts
const scriptName = script.src.split('/').pop();
const alreadyPreloaded = document.querySelector(`link[rel="preload"][href*="${scriptName}"]`) ||
document.querySelector(`script[src*="${scriptName}"]`);
if (alreadyPreloaded) {
console.log(`Script already preloaded or loaded: ${script.id}`);
return;
}
const preload = document.createElement('link');
preload.rel = 'preload';
preload.href = `${script.src}?v=${this.version}`;
preload.as = 'script';
document.head.appendChild(preload);
console.log(`Script preloaded: ${script.id}`);
});
}
/**
* Load all necessary scripts
*/
async loadScripts() {
console.log('EnhancedGallerySystem: Loading essential scripts');
// Load sequentially (main before hp) so neon-gallery-main assigns window.neonGalleryUtils
// before hp's initNeonGallery can run; parallel async scripts could execute hp before main finishes.
const loadOne = (script) => {
const alreadyLoaded = this.loadedScripts.has(script.id) ||
document.getElementById(script.id) ||
document.querySelector(`script[src*="${script.src.split('/').pop()}"]`);
if (alreadyLoaded) {
const mainNeedsReload =
script.id === 'neon-gallery-main' &&
typeof window.neonGalleryUtils?.loadGallerySettings !== 'function';
if (mainNeedsReload) {
console.warn(`EnhancedGallerySystem: ${script.id} in DOM but neonGalleryUtils incomplete — reloading`);
document.querySelectorAll('script[src*="neon-gallery-main"]').forEach(el => {
if (el.parentNode) el.parentNode.removeChild(el);
});
this.loadedScripts.delete(script.id);
try { delete window.__neonGalleryMainBootstrapWipeDone; } catch (e) { window.__neonGalleryMainBootstrapWipeDone = false; }
} else {
console.log(`Script already loaded: ${script.id}`);
this.loadedScripts.add(script.id);
return Promise.resolve();
}
}
return new Promise((resolve, reject) => {
const scriptElement = document.createElement('script');
scriptElement.id = script.id;
let scriptUrl = `${script.src}?v=${this.version}`;
if (window.useCdnProxy && window.convertCdnToProxyUrl && script.src.includes('cdn.neonsky.app')) {
scriptUrl = window.convertCdnToProxyUrl(script.src) + '?v=' + this.version;
console.log('🔍 Using proxy URL for script:', script.id, scriptUrl);
}
scriptElement.src = scriptUrl;
scriptElement.async = false;
scriptElement.onload = () => {
console.log(`✅ Script loaded: ${script.id}`);
this.loadedScripts.add(script.id);
resolve();
};
scriptElement.onerror = (err) => {
console.error(`❌ Failed to load script: ${script.id}`, err);
if (script.src.includes('cdn.neonsky.app') && !scriptUrl.includes('/cdn-proxy/')) {
console.error('🔄 Retrying script via proxy:', script.id);
const proxyUrl = window.convertCdnToProxyUrl ? window.convertCdnToProxyUrl(script.src) : script.src.replace('https://cdn.neonsky.app/', window.location.origin + '/cdn-proxy/');
scriptElement.src = proxyUrl + '?v=' + this.version;
scriptElement.onerror = function() {
console.error('❌ Proxy failed, trying storage:', proxyUrl);
scriptElement.src = scriptElement.src.replace('cdn.neonsky.app', 'storage.neonsky.app');
};
} else {
reject(new Error(`Failed to load ${script.id}`));
}
};
document.body.appendChild(scriptElement);
});
};
for (const script of this.essentialResources.scripts) {
await loadOne(script);
}
console.log('EnhancedGallerySystem: All scripts loaded');
// Save references to original initialization functions after scripts are loaded
if (window.initNeonGallery && typeof window.initNeonGallery === 'function') {
this.originalInitFunctions.initNeonGallery = window.initNeonGallery;
}
if (window.reinitializeGalleryAfterSave && typeof window.reinitializeGalleryAfterSave === 'function') {
this.originalInitFunctions.reinitializeGalleryAfterSave = window.reinitializeGalleryAfterSave;
}
}
/**
* Set up enhanced event listeners
*/
setupEvents() {
// Listen for gallery save events to capture latest state
document.addEventListener('gallery-saved', (event) => {
if (event.detail && event.detail.settings) {
this.captureGalleryState(event.detail.settings);
}
});
// Override existing functions to capture state changes
this.enhanceExistingFunctions();
}
/**
* Enhance existing gallery functions to work with our system
*/
enhanceExistingFunctions() {
// Save original functions
const originalInit = window.initNeonGallery;
const originalReinit = window.reinitializeGalleryAfterSave;
// Only enhance if they exist
if (typeof originalInit === 'function') {
// Track if initNeonGallery is currently executing to prevent concurrent calls
let isExecuting = false;
let lastExecutionTime = 0;
window.initNeonGallery = async (...args) => {
// Prevent duplicate initializations within 1 second
const now = Date.now();
// Check if already executing or called recently
if (isExecuting) {
console.log('EnhancedGallerySystem: initNeonGallery already executing, skipping duplicate call');
return Promise.resolve();
}
if (lastExecutionTime && (now - lastExecutionTime) < 1000) {
console.log('EnhancedGallerySystem: Skipping duplicate initNeonGallery call (last call ' + (now - lastExecutionTime) + 'ms ago)');
return Promise.resolve();
}
// Set flag and timestamp immediately to prevent concurrent calls
isExecuting = true;
lastExecutionTime = now;
this.lastInitializationTime.set('initNeonGallery', now);
try {
// Call original with all arguments
const result = await originalInit.apply(this, args);
// Capture state after initialization
if (window.neonGalleryConfig) {
this.captureGalleryState(window.neonGalleryConfig);
}
return result;
} finally {
// Clear execution flag after a short delay to allow for async operations
setTimeout(() => {
isExecuting = false;
}, 100);
}
};
}
if (typeof originalReinit === 'function') {
window.reinitializeGalleryAfterSave = async (savedSettings) => {
// Call original function
const result = await originalReinit(savedSettings);
// Capture updated state
this.captureGalleryState(savedSettings);
return result;
};
}
}
/**
* Capture current gallery state for potential reuse
*/
captureGalleryState(settings) {
if (!settings) return;
// Store a deep copy of the settings to avoid reference issues
try {
this.previousGalleryState = JSON.parse(JSON.stringify(settings));
console.log('EnhancedGallerySystem: Captured gallery state for possible reuse');
} catch (e) {
console.error('EnhancedGallerySystem: Failed to capture gallery state:', e);
}
}
/**
* Load a specific gallery using the enhanced system
* @param {Object} gallery The gallery configuration
* @param {HTMLElement} container The container to load the gallery into
*/
async loadGallery(gallery, container, options) {
if (!gallery) {
console.error('EnhancedGallerySystem: Invalid gallery configuration');
return false;
}
const forceGalleryReload = options?.forceReload === true || window._forceGalleryReload === true;
// Coalesce concurrent loads for the same gallery — await instead of wiping mid-flight
if (this._loadInProgress && this._loadingGalleryId === gallery.id && !forceGalleryReload) {
console.log('EnhancedGallerySystem: Awaiting in-flight load for gallery ' + gallery.id);
return this._loadInProgress;
}
const now = Date.now();
const lastInitTime = this.lastInitializationTime.get(gallery.id);
if (!forceGalleryReload && lastInitTime && (now - lastInitTime) < 1000) {
const hasContent = !!document.querySelector('.gallery-wrapper .masonry-item, .gallery-wrapper .horizontal-scroller, .gallery-wrapper .masonry');
if (hasContent) {
console.log('EnhancedGallerySystem: Gallery ' + gallery.id + ' recently loaded with content, skipping re-init');
if (container) container.style.opacity = '1';
if (typeof window.syncGalleryOptionsButtonVisibility === 'function') {
window.syncGalleryOptionsButtonVisibility();
}
return true;
}
}
if (forceGalleryReload) {
console.log('EnhancedGallerySystem: force reload requested for gallery ' + gallery.id);
this.lastInitializationTime.delete(gallery.id);
this._loadInProgress = null;
this._loadingGalleryId = null;
}
const loadTask = this._executeGalleryLoad(gallery, container, now, options);
this._loadInProgress = loadTask;
this._loadingGalleryId = gallery.id;
try {
return await loadTask;
} finally {
if (this._loadInProgress === loadTask) {
this._loadInProgress = null;
this._loadingGalleryId = null;
}
}
}
async _executeGalleryLoad(gallery, container, startedAt, options) {
this.lastInitializationTime.set(gallery.id, startedAt);
stopAutoAdvanceTimer();
// CRITICAL: Verify container exists
if (!container) {
console.error('EnhancedGallerySystem: Gallery container is null or undefined');
return false;
}
// Create a unique instance ID for this gallery load (needed before script init)
const galleryInstanceId = Date.now();
try {
// Clean up previous gallery state and prepare container BEFORE loading scripts
this.cleanupGalleryState(container);
const containerSetup = this.setupCorrectGalleryContainer(container);
if (!containerSetup) {
throw new Error('Failed to set up gallery container');
}
this.setupGalleryParameters(gallery, galleryInstanceId);
} catch (prepError) {
console.error('EnhancedGallerySystem: Failed to prepare gallery container:', prepError);
return false;
}
// Make sure the system is initialized (scripts load with container already in DOM)
try {
await this.initialize();
} catch (error) {
console.error('EnhancedGallerySystem: Failed to initialize gallery framework:', error);
//this.showErrorMessage(container, 'Failed to initialize gallery system. Please try again later.');
return false;
}
if (!(await this.ensureNeonGalleryUtilsReady(container))) {
console.error('EnhancedGallerySystem: neonGalleryUtils.loadGallerySettings unavailable after script load');
return false;
}
// Update current gallery tracking
this.currentGalleryId = gallery.id;
console.log(`EnhancedGallerySystem: Loading gallery ${gallery.title} (ID: ${gallery.id}, Instance: ${galleryInstanceId})`);
try {
// Determine which initialization method to use
if (this.canUseReinitForGallery(gallery, options)) {
return await this.reinitializeGallery(gallery, container);
} else {
return await this.initializeNewGallery(gallery, container, galleryInstanceId);
}
} catch (error) {
console.error('EnhancedGallerySystem: Error loading gallery:', error);
if (container) {
console.error('EnhancedGallerySystem: Error loading gallery:', error);
//this.showErrorMessage(container, 'There was a problem loading the gallery. Please try again later.');
}
return false;
}
}
/**
* Determine if we can use reinitialization for this gallery
*/
canUseReinitForGallery(gallery, options) {
if (options?.forceReload === true || window._forceGalleryReload === true) {
return false;
}
// Check if reinitializeGalleryAfterSave function exists
if (typeof window.reinitializeGalleryAfterSave !== 'function') {
return false;
}
// Check if we have a previous state to reuse
if (!this.previousGalleryState) {
return false;
}
// Check if the gallery has the necessary properties for reinit
if (!gallery.pageId || !gallery.galleryOptions) {
return false;
}
// CRITICAL: Check if the gallery wrapper actually exists in the DOM
// Reinitialization requires an existing gallery structure
const galleryWrapper = document.querySelector('.gallery-wrapper');
const neonContainer = document.getElementById('neon-gallery-container');
if (!galleryWrapper || !neonContainer) {
console.log('EnhancedGallerySystem: Cannot use reinit - gallery wrapper or container not found');
return false;
}
return true;
}
/**
* Reinitialize a gallery using the reinitialization process
*/
async reinitializeGallery(gallery, container) {
console.log('EnhancedGallerySystem: Using reinitialization process for gallery', gallery.id);
try {
// CRITICAL: Ensure CSS is loaded before attempting reinitialization
ensureGalleryStylesLoaded();
// We should already have the proper container, but just in case:
if (!document.getElementById('neon-gallery-container')) {
this.setupCorrectGalleryContainer(container);
}
// CRITICAL: Ensure config is set up with all required fields before reinitialization
const galleryOptions = gallery.galleryOptions || {};
window.neonGalleryConfig = {
useData: true,
useCDN: true,
version: 'live',
manualCollectionName: galleryOptions.manualCollectionName || "mod",
layoutType: galleryOptions.layoutType || "grid",
...galleryOptions,
siteId: window.siteId || gallery.siteId || '',
galleryId: gallery.id,
// Explicitly include all permanent storage fields
isPerma: galleryOptions.isPerma,
permaURL: galleryOptions.permaURL,
loadTxId: galleryOptions.loadTxId,
loadPermaURL: galleryOptions.loadPermaURL,
containerSelector: '#neon-gallery-container'
};
// Prepare settings by merging previous state with new gallery options
const settings = {
...this.previousGalleryState,
...gallery.galleryOptions,
pageId: gallery.pageId,
forceReload: true,
containerSelector: '#neon-gallery-container' // Add this explicitly
};
// Call reinitializeGalleryAfterSave with the merged settings
window._reinitializeGalleryFromSystem = true;
let result;
try {
result = await window.reinitializeGalleryAfterSave(settings);
} finally {
window._reinitializeGalleryFromSystem = false;
}
if (result) {
console.log('EnhancedGallerySystem: Gallery reinitialization completed successfully');
// Fade in the container
container.style.opacity = '1';
return true;
} else {
throw new Error('Reinitialization did not complete successfully');
}
} catch (error) {
console.error('EnhancedGallerySystem: Error reinitializing gallery:', error);
// Fall back to standard initialization - ensure config is set up properly
const galleryInstanceId = Date.now();
// Re-setup parameters and config for the fallback
this.setupGalleryParameters(gallery, galleryInstanceId);
return this.initializeNewGallery(gallery, container, galleryInstanceId);
}
}
/**
* Initialize a gallery from scratch (fallback method)
*/
async initializeNewGallery(gallery, container, galleryInstanceId) {
console.log('EnhancedGallerySystem: Using standard initialization for gallery', gallery.id);
try {
// CRITICAL: Ensure CSS is loaded BEFORE creating any gallery elements
ensureGalleryStylesLoaded();
// Clean up container
if (container) {
container.innerHTML = '';
} else {
console.error('EnhancedGallerySystem: Container is null in initializeNewGallery');
return false;
}
// Create gallery container with unique ID
const galleryContainerId = `gallery-container-${gallery.id}-${galleryInstanceId}`;
const neonGalleryContainer = document.createElement('div');
neonGalleryContainer.id = 'neon-gallery-container';
neonGalleryContainer.className = 'gallery-direct-content';
neonGalleryContainer.setAttribute('data-gallery-id', gallery.id.toString());
neonGalleryContainer.setAttribute('data-instance-id', galleryInstanceId.toString());
neonGalleryContainer.setAttribute('data-unique-id', galleryContainerId);
neonGalleryContainer.style.width = '100%';
neonGalleryContainer.style.height = '100%';
container.appendChild(neonGalleryContainer);
// CRITICAL: Initialize neonGalleryState if it doesn't exist
if (!window.neonGalleryState) {
window.neonGalleryState = {
initialized: false,
initializing: false,
domInitialized: false,
configApplied: false,
initializationPromise: null
};
}
// CRITICAL: Always ensure neonGalleryConfig is properly set with ALL required fields
// Don't rely on conditional logic - always set it correctly here
const galleryOptions = gallery.galleryOptions || {};
window.neonGalleryConfig = {
useData: true,
useCDN: true,
version: 'live',
manualCollectionName: galleryOptions.manualCollectionName || "mod",
layoutType: galleryOptions.layoutType || "grid",
...galleryOptions,
siteId: window.siteId || gallery.siteId || '',
galleryId: gallery.id,
galleryInstanceId: galleryInstanceId,
containerSelector: '#neon-gallery-container',
// Explicitly include all permanent storage fields (critical for proper initialization)
isPerma: galleryOptions.isPerma,
permaURL: galleryOptions.permaURL,
loadTxId: galleryOptions.loadTxId,
loadPermaURL: galleryOptions.loadPermaURL
};
// Call the initialization function directly if available
if (typeof window.initNeonGallery === 'function') {
console.log('EnhancedGallerySystem: Calling direct initialization method');
// Additional check: prevent duplicate initNeonGallery calls within 1 second
const initKey = 'init_' + gallery.id + '_' + galleryInstanceId;
const lastInitCall = this.lastInitializationTime.get(initKey);
const now = Date.now();
if (lastInitCall && (now - lastInitCall) < 1000) {
console.log('EnhancedGallerySystem: Skipping duplicate initNeonGallery call for gallery ' + gallery.id + ' (last call ' + (now - lastInitCall) + 'ms ago)');
container.style.opacity = '1';
return true;
}
this.lastInitializationTime.set(initKey, now);
try {
// Wait a tiny bit to ensure DOM updates have propagated
await new Promise(resolve => setTimeout(resolve, 10));
const result = await window.initNeonGallery();
console.log('EnhancedGallerySystem: Direct initialization successful');
// Save state for future loads
if (window.neonGalleryConfig) {
this.captureGalleryState(window.neonGalleryConfig);
}
// Fade in the container
container.style.opacity = '1';
return true;
} catch (e) {
console.error('EnhancedGallerySystem: Direct initialization failed:', e);
throw e;
}
} else {
// If initNeonGallery isn't available, use event-based initialization
console.log('EnhancedGallerySystem: Using event-based initialization fallback');
// Create an event to trigger initialization
const initEvent = new CustomEvent('neon-gallery-init', {
detail: {
galleryId: gallery.id,
instanceId: galleryInstanceId,
containerId: galleryContainerId
}
});
document.dispatchEvent(initEvent);
// Wait a short time to see if initialization completes
await new Promise(resolve => setTimeout(resolve, 500));
// Fade in regardless of result - gallery script should handle displaying errors
container.style.opacity = '1';
return true;
}
} catch (error) {
console.error('EnhancedGallerySystem: Error in standard initialization:', error);
if (container) {
this.showErrorMessage(container, 'Failed to load gallery content. Please try again later.');
}
return false;
}
}
/**
* Clean up gallery state without removing loaded scripts
*/
cleanupGalleryState(container) {
console.log('EnhancedGallerySystem: Cleaning up gallery state');
// Stop any ongoing singles autoplay from the previous gallery
if (typeof window.stopSinglesAutoplay === 'function') {
try {
window.stopSinglesAutoplay();
console.log('EnhancedGallerySystem: Called stopSinglesAutoplay() during cleanup.');
} catch (error) {
console.warn('EnhancedGallerySystem: stopSinglesAutoplay() failed during cleanup:', error);
}
} else {
console.warn('EnhancedGallerySystem: stopSinglesAutoplay() function not found during cleanup.');
}
// 1. Reset global gallery state variables
const resetGlobals = [
'neonGalleryInitComplete', 'neonGalleryInitInProgress',
'neonGalleryLoaded', 'neonGalleryConfig', 'neonGalleryCache', 'neonGalleryImages',
'neonGallerySettings', 'neonLightbox', 'neonGalleryEventListeners',
'neonGalleryInstance', 'currentGalleryId', 'galleryData',
'imageCache', 'thumbnailCache'
];
resetGlobals.forEach(prop => {
if (window[prop] !== undefined) {
window[prop] = null;
}
});
// 2. Reset neonGalleryState if it's a proper object with properties
if (window.neonGalleryState && typeof window.neonGalleryState === 'object') {
window.neonGalleryState = {
initialized: false,
initializing: false,
domInitialized: false,
configApplied: false,
initializationPromise: null
};
}
// 3. Remove lightbox elements and overlays
document.querySelectorAll('#neon-lightbox, .neon-lightbox, .lightbox-container').forEach(el => {
if (el && el.parentNode) {
el.parentNode.removeChild(el);
}
});
const staleOptionsOverlay = document.getElementById('optionsOverlay');
if (staleOptionsOverlay && staleOptionsOverlay.parentNode) {
staleOptionsOverlay.parentNode.removeChild(staleOptionsOverlay);
}
window.optionsVisible = false;
// 4. Remove any gallery-specific DOM elements
document.querySelectorAll(
'.neon-gallery-wrapper, .fullscreen-overlay, ' +
'.neon-gallery-modal, .gallery-tooltip, .neon-gallery-context-menu, ' +
'.neon-thumbnails, .neon-image, .neon-caption, ' +
'.neon-controls, .neon-pagination'
).forEach(el => {
if (el && el.parentNode) {
el.parentNode.removeChild(el);
}
});
// 5. Clear container if provided and valid
if (container) {
container.innerHTML = '';
container.style.opacity = '0';
}
// 6. Clear keyboard listeners to prevent multiple listeners from different galleries
if (typeof window.removeAllLightboxKeyboardListeners === 'function') {
window.removeAllLightboxKeyboardListeners();
console.log('EnhancedGallerySystem: Cleared all lightbox keyboard listeners during cleanup');
} else if (window.currentLightboxKeyboardListener) {
document.removeEventListener('keydown', window.currentLightboxKeyboardListener);
window.currentLightboxKeyboardListener = null;
console.log('EnhancedGallerySystem: Cleared keyboard listeners during cleanup');
}
window.lightboxKeyboardListenerActive = false;
}
/**
* Prepare container for gallery loading
*/
prepareContainer(container, gallery, instanceId) {
// CRITICAL: Add null check for container
if (!container) {
console.error('EnhancedGallerySystem: Container is null in prepareContainer');
return;
}
try {
// Add attributes and data
container.setAttribute('data-gallery-id', gallery.id.toString());
container.setAttribute('data-instance-id', instanceId.toString());
// Add loading indicator
const loadingIndicator = document.createElement('div');
loadingIndicator.className = 'gallery-loading';
loadingIndicator.innerHTML = `
<div class="spinner"></div>
<div class="loading-text">Loading Gallery...</div>
`;
container.appendChild(loadingIndicator);
} catch (error) {
console.error('EnhancedGallerySystem: Error preparing container:', error);
}
}
setupGalleryParameters(gallery, galleryInstanceId) {
// Set up gallery parameters with enhanced uniqueness
window.Parameters = window.Parameters || {};
// Clone and extend existing Parameters to avoid losing important values
const existingParams = { ...window.Parameters };
window.Parameters = {
...existingParams,
SiteAlias: window.location.hostname,
InitialPageUuid: gallery.pageId,
InitialPageAlias: gallery.slug || this.slugify(gallery.title),
// CRITICAL FIX: Set isInEditor to true if user is logged in as admin OR in edit mode
// This ensures gallery files bypass cache for any logged-in admin, not just when edit mode is active
isInEditor: window.isEditing ||
localStorage.getItem('hydra_is_admin') === 'true' ||
window.isAdmin === true ||
document.body.classList.contains('hydra-admin'),
siteId: window.siteId || gallery.siteId || '',
isHydra: true,
// CRITICAL: These properties help the gallery script find the container
galleryContainerId: 'neon-gallery-container',
containerSelector: '#neon-gallery-container',
// Additional properties
galleryInstanceId: galleryInstanceId,
galleryUniqueId: `gallery-container-${gallery.id}-${galleryInstanceId}`,
loadTimestamp: galleryInstanceId,
loadedGalleryId: gallery.id
};
// Set up gallery config - CRITICAL: Include all permanent storage fields
const galleryOptions = gallery.galleryOptions || {};
window.neonGalleryConfig = {
useData: true,
useCDN: true,
version: 'live',
manualCollectionName: galleryOptions.manualCollectionName || "mod",
layoutType: galleryOptions.layoutType || "grid",
...galleryOptions,
siteId: window.siteId || gallery.siteId || '',
galleryId: gallery.id,
galleryInstanceId: galleryInstanceId,
// Add container selector here too
containerSelector: '#neon-gallery-container',
// CRITICAL: Explicitly include all permanent storage fields to ensure they're available
// These are needed for proper gallery initialization and data loading
isPerma: galleryOptions.isPerma,
permaURL: galleryOptions.permaURL,
loadTxId: galleryOptions.loadTxId,
loadPermaURL: galleryOptions.loadPermaURL
};
}
/**
* Show error message in container
*/
showErrorMessage(container, message) {
// CRITICAL: Add null check for container
if (!container) {
console.error('EnhancedGallerySystem: Container is null in showErrorMessage');
return;
}
try {
// Clear container
container.innerHTML = '';
// Create error message element
const errorElement = document.createElement('div');
errorElement.className = 'gallery-error';
errorElement.innerHTML = `
<h3>Error Loading Gallery</h3>
<p>${message}</p>
`;
container.appendChild(errorElement);
container.style.opacity = '1';
} catch (error) {
console.error('EnhancedGallerySystem: Error showing error message:', error);
}
}
/**
* Utility function to convert title to slug
*/
slugify(text) {
return text
.toLowerCase()
.replace(/s+/g, '-')
.replace(/[^w-]+/g, '')
.replace(/--+/g, '-')
.trim();
}
}
// Create a singleton instance
window.EnhancedGallerySystem = window.EnhancedGallerySystem || new EnhancedGallerySystem();
/**
* Enhanced loadGallery function using the EnhancedGallerySystem
* This replaces the existing loadGallery function
*/
const enhancedLoadGallery = async function(id, event, options) {
const originalLoadGallery = window.originalLoadGallery;
const forceGalleryReload = options?.forceReload === true || window._forceGalleryReload === true;
stopAutoAdvanceTimer();
if (event) {
event.preventDefault();
event.stopPropagation();
}
let galleryContainer = document.querySelector('.gallery-container');
if (!galleryContainer) {
galleryContainer = document.getElementById('galleryFrame')?.parentElement ||
document.getElementById('main-content') ||
document.getElementById('content');
if (!galleryContainer) {
console.error('Gallery container not found, falling back to original function');
if (typeof originalLoadGallery === 'function') {
return originalLoadGallery(id, event, options);
} else {
console.error('Gallery container not found and no fallback available');
return;
}
}
}
// Bail out before hiding/clearing when the same gallery is already loading or rendered.
// Post-save passes { forceReload: true } to bypass this path without affecting navigation.
const egEarly = window.EnhancedGallerySystem;
if (egEarly && !forceGalleryReload) {
if (egEarly._loadInProgress && egEarly._loadingGalleryId === id) {
console.log('enhancedLoadGallery: awaiting in-flight load for gallery', id);
await egEarly._loadInProgress;
if (galleryContainer) galleryContainer.style.opacity = '1';
return;
}
const hasRenderedGallery = !!document.querySelector(
'.gallery-wrapper .masonry-item, .gallery-wrapper .horizontal-scroller, .gallery-wrapper .masonry'
);
if (hasRenderedGallery && egEarly.currentGalleryId === id) {
console.log('enhancedLoadGallery: gallery already rendered, skipping reload');
if (galleryContainer) galleryContainer.style.opacity = '1';
if (typeof window.syncGalleryOptionsButtonVisibility === 'function') {
window.syncGalleryOptionsButtonVisibility();
}
return;
}
} else if (forceGalleryReload) {
console.log('enhancedLoadGallery: force reload requested, bypassing already-rendered skip');
if (egEarly?.lastInitializationTime?.delete) {
egEarly.lastInitializationTime.delete(id);
}
if (egEarly) {
egEarly._loadInProgress = null;
egEarly._loadingGalleryId = null;
}
}
if (galleryContainer) {
galleryContainer.style.opacity = '0';
}
console.log('Loading gallery with ID (enhancedLoadGallery):', id);
const gallery = findGalleryById(galleries, id);
if (!gallery) {
console.warn('No gallery found with ID:', id);
if (galleryContainer) {
galleryContainer.innerHTML = `
<div class="gallery-error">
<h3>Gallery Not Found</h3>
<p>The requested gallery could not be found.</p>
</div>
`;
galleryContainer.style.opacity = '1';
}
return;
}
if (isPageMenuItem(gallery)) {
return typeof window.loadPage === 'function' ? window.loadPage(id, event) : loadPage(id, event);
}
window._activeContentType = 'gallery';
if (gallery.isSubmenu) {
console.log('Gallery is submenu, skipping URL update');
toggleSubmenu(id, event || { stopPropagation: () => {} });
if (galleryContainer){
galleryContainer.style.opacity = '1';
}
return;
}
console.log('Found gallery:', gallery.title);
// Check for gallery-level password protection (only for visitors, not in edit mode)
if (!isInEditMode() && gallery.passwordProtected) {
// Use PageManager's gallery password protection methods
if (window.PageManager && typeof window.PageManager.checkGalleryPasswordAccess === 'function' &&
typeof window.PageManager.createGalleryPasswordOverlay === 'function') {
if (!window.PageManager.checkGalleryPasswordAccess(gallery)) {
console.log('Gallery is password protected, showing overlay');
window.PageManager.createGalleryPasswordOverlay(gallery, (accessGranted) => {
if (accessGranted) {
// Reload the gallery now that access is granted
enhancedLoadGallery(id, event);
}
});
return; // Exit early, will reload after password is entered
}
}
}
window.activeGalleryId = id;
activeGalleryId = id;
// CRITICAL: Clean up page-embedded gallery scripts/DOM before loading a standalone gallery.
// loadPage() calls removeGalleryScriptsWithPause which can interrupt EnhancedGallerySystem.initialize(),
// leaving isLoading:true with a never-resolving initPromise. Re-run cleanup here (matching original
// loadGallery) and clear the container so page content does not block gallery rendering.
if (typeof window.removeGalleryScriptsWithPause === 'function') {
await window.removeGalleryScriptsWithPause();
}
if (galleryContainer) {
galleryContainer.innerHTML = '';
}
const bodyEl = document.body;
if (shouldApplyMenuHiddenForEmbedOrGallery(gallery)) {
bodyEl.classList.add('menu-hidden-on-page');
} else {
bodyEl.classList.remove('menu-hidden-on-page');
}
try {
if (typeof updateActiveStates === 'function') updateActiveStates();
if (typeof updateMobileTitle === 'function') updateMobileTitle();
if (typeof closeMobileMenu === 'function') closeMobileMenu();
// Close the gallery options panel when navigating to a different gallery
if (typeof window.closeOptionsPanel === 'function' && !forceGalleryReload) {
window.closeOptionsPanel();
}
const staleOptionsOverlay = document.getElementById('optionsOverlay');
if (staleOptionsOverlay) {
staleOptionsOverlay.remove();
}
window.optionsVisible = false;
if (typeof updateURLWithGallerySlug === 'function') {
updateURLWithGallerySlug(gallery);
}
} catch (uiError) {
console.error('Error updating UI state:', uiError);
}
try {
// Add detailed logging about EnhancedGallerySystem availability
console.log('🔍 Checking EnhancedGallerySystem availability:', {
exists: !!window.EnhancedGallerySystem,
hasLoadGallery: !!(window.EnhancedGallerySystem && typeof window.EnhancedGallerySystem.loadGallery === 'function'),
initialized: !!(window.EnhancedGallerySystem && window.EnhancedGallerySystem.initialized),
isLoading: !!(window.EnhancedGallerySystem && window.EnhancedGallerySystem.isLoading)
});
if (window.EnhancedGallerySystem && typeof window.EnhancedGallerySystem.loadGallery === 'function') {
console.log('✅ EnhancedGallerySystem available, calling loadGallery');
const result = await window.EnhancedGallerySystem.loadGallery(
gallery,
galleryContainer,
forceGalleryReload ? { forceReload: true } : options
);
console.log('📊 EnhancedGallerySystem.loadGallery result:', result);
if (!result && typeof originalLoadGallery === 'function') {
console.log('⚠️ Enhanced gallery loading failed, falling back to original function');
return originalLoadGallery(id, event, options);
}
if (galleryContainer) {
galleryContainer.style.opacity = '1';
}
if (typeof window.syncGalleryOptionsButtonVisibility === 'function') {
window.syncGalleryOptionsButtonVisibility();
setTimeout(window.syncGalleryOptionsButtonVisibility, 350);
}
} else {
console.error('❌ EnhancedGallerySystem or its loadGallery method is not available.', {
EnhancedGallerySystem: !!window.EnhancedGallerySystem,
loadGallery: !!(window.EnhancedGallerySystem && typeof window.EnhancedGallerySystem.loadGallery === 'function'),
useCdnProxy: window.useCdnProxy || false
});
// Fallback: Try using createGalleryContext directly
console.log('🔄 Attempting fallback: createGalleryContext');
if (typeof createGalleryContext === 'function') {
createGalleryContext(gallery, galleryContainer);
if (galleryContainer) {
galleryContainer.style.opacity = '1';
}
return;
}
if (galleryContainer) {
galleryContainer.innerHTML = `<div class="gallery-error">Gallery system not available. EnhancedGallerySystem: ${!!window.EnhancedGallerySystem}, loadGallery: ${!!(window.EnhancedGallerySystem && typeof window.EnhancedGallerySystem.loadGallery === 'function')}</div>`;
galleryContainer.style.opacity = '1';
}
return;
}
} catch (error) {
console.error('Error loading gallery:', error);
if (galleryContainer) {
try {
galleryContainer.innerHTML = `
<div class="gallery-error">
<h3>Error Loading Gallery</h3>
<p>There was a problem loading the gallery. Please try again later.</p>
<p class="error-details">${error.message}</p>
</div>
`;
galleryContainer.style.opacity = '1';
} catch (containerError) {
console.error('Error showing error message in container:', containerError);
}
}
if (typeof originalLoadGallery === 'function') {
console.log('Falling back to original loadGallery after error');
return originalLoadGallery(id, event, options);
}
}
};
/**
* Initialization function to set up the gallery system
*/
function initializeGallerySystem() {
console.log('Initializing enhanced gallery system');
// Store reference to original function before replacing
if (typeof window.loadGallery === 'function') {
window.originalLoadGallery = window.loadGallery;
}
// Replace the existing loadGallery function with our optimized version
window.loadGallery = enhancedLoadGallery;
// Add this CSS for loading/error states
addGallerySystemStyles();
// Preload styles only at boot — scripts load when first gallery opens (with container ready)
window.EnhancedGallerySystem.preloadStylesOnly().catch(error => {
console.warn('Gallery styles preloading failed:', error);
});
}
/**
* Add CSS styles for loading indicators and error messages
*/
function addGallerySystemStyles() {
const styleId = 'enhanced-gallery-system-styles';
// Skip if already added
if (document.getElementById(styleId)) {
return;
}
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
/* Loading indicator */
.gallery-loading {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
min-height: 200px;
opacity: 0 !important;
}
.gallery-loading .spinner {
width: 50px;
height: 50px;
border: 3px solid rgba(0, 0, 0, 0.1);
border-radius: 50%;
border-top-color: #4682B4;
animation: spin 1s ease-in-out infinite;
margin-bottom: 20px;
opacity: 0 !important;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.gallery-loading .loading-text {
font-family: Arial, sans-serif;
font-size: 16px;
color: #555;
opacity: 0;
}
/* Error message */
.gallery-error {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
min-height: 200px;
padding: 30px;
text-align: center;
}
.gallery-error h3 {
font-family: Arial, sans-serif;
font-size: 24px;
color:rgba(211, 47, 47, 0.25);
margin-bottom: 20px;
}
.gallery-error p {
font-family: Arial, sans-serif;
font-size: 16px;
color: #555;
max-width: 600px;
margin-bottom: 10px;
}
.gallery-error .error-details {
font-size: 14px;
color: #888;
font-style: italic;
}
`;
document.head.appendChild(style);
}
// Initialize gallery system immediately if possible
initializeGallerySystem();
})();
/**
* Enhanced Gallery Link Interceptor
* Handles clicks on gallery links to prevent full page reloads
*/
(function() {
// Store any loaded gallery data for quick reference
const GALLERY_DATA_CACHE = {
initialized: false,
galleries: [],
lastUpdate: 0
};
// Initialize when DOM is ready to catch early clicks
document.addEventListener('DOMContentLoaded', initGalleryLinkInterceptor);
// Also initialize when window loads to ensure complete gallery data
window.addEventListener('load', function() {
// Use a small delay to ensure galleries have fully initialized
setTimeout(initGalleryLinkInterceptor, 200);
});
// Additional init on first user interaction
document.addEventListener('click', function initOnFirstClick() {
initGalleryLinkInterceptor();
// Remove this listener after first click
document.removeEventListener('click', initOnFirstClick);
}, { once: true });
// Main initialization function
function initGalleryLinkInterceptor() {
// Prevent multiple initializations
if (window._galleryLinkInterceptorInitialized) {
// If already initialized, just refresh the gallery data
refreshGalleryCache();
return;
}
window._galleryLinkInterceptorInitialized = true;
console.log('Initializing enhanced gallery link interceptor');
// Update our cache with current galleries data
refreshGalleryCache();
// Get primary containers that might contain links
const galleryContainer = document.querySelector('.gallery-container');
const mainContainer = document.querySelector('.container') || document.body;
if (!galleryContainer && !mainContainer) {
console.warn('No gallery containers found, link interceptor partially initialized');
}
// Add global click handler to catch all link clicks
document.addEventListener('click', handleGlobalLinkClick, true);
// Try to intercept links from iframes - critical for gallery content
interceptAllIframeLinks();
// Set up a mutation observer to handle dynamically added content
setupMutationObserver();
// Log initialization with site details
const isPreviewMode = window.location.hostname === 'preview.neonsky.app';
console.log(`Gallery link interceptor initialized. Preview mode: ${isPreviewMode}`);
// Create global helper for debugging
window.debugGalleryLinks = function() {
return {
inPreviewMode: isPreviewMode,
interceptorInitialized: window._galleryLinkInterceptorInitialized,
cachedGalleries: GALLERY_DATA_CACHE.galleries.length,
urlPath: window.location.pathname,
siteGuid: getSiteGuidFromPath()
};
};
}
// Update the gallery cache when gallery data changes
function refreshGalleryCache() {
// Initialize GALLERY_DATA_CACHE if it doesn't exist
if (!window.GALLERY_DATA_CACHE) {
window.GALLERY_DATA_CACHE = {
initialized: false,
galleries: [],
lastUpdate: 0
};
}
// Check for global galleries variable first
if (typeof galleries !== 'undefined' && Array.isArray(galleries)) {
// Use the global galleries array
window.GALLERY_DATA_CACHE.galleries = galleries;
window.GALLERY_DATA_CACHE.initialized = true;
window.GALLERY_DATA_CACHE.lastUpdate = Date.now();
window.galleries = galleries;
console.log(`Gallery cache refreshed with ${galleries.length} galleries from global variable`);
return;
}
// Debug log
console.log(`Gallery cache refreshed with ${GALLERY_DATA_CACHE.galleries.length} galleries`);
}
// Global click handler - catches all link clicks
function handleGlobalLinkClick(e) {
// Find if a link was clicked
const link = e.target.closest('a');
if (!link || !link.href) return;
// Skip if the link uses special targets or modifiers
if (link.target === '_blank' || e.ctrlKey || e.metaKey || e.shiftKey) return;
// Skip for hash links or javascript: links
if (link.href.includes('#') || link.href.startsWith('javascript:')) return;
try {
// Parse the URL to work with it
const url = new URL(link.href);
// Only intercept links to the same domain
if (url.hostname !== window.location.hostname) return;
// Skip API and resource URLs
if (url.pathname.includes('/api/') || isResourceUrl(url.pathname)) return;
console.log('Link intercepted:', url.pathname);
// For preview URLs, we need to handle the path specially
const isPreviewMode = window.location.hostname === 'preview.neonsky.app';
const siteGuid = getSiteGuidFromPath();
console.log('Link interceptor - isPreviewMode:', isPreviewMode, 'siteGuid:', siteGuid);
// Site Graph card links carry data-sg-target-id — use SPA navigation (not preview iframe fallback)
if (link.classList.contains('sg-card-link') && link.closest('.site-graph-element') && link.dataset.sgTargetId) {
let galleriesData = (typeof galleries !== 'undefined' && Array.isArray(galleries))
? galleries
: window.galleries;
const sgItem = Array.isArray(galleriesData)
? findGalleryById(galleriesData, link.dataset.sgTargetId)
: null;
if (sgItem) {
e.preventDefault();
e.stopPropagation();
if (sgItem.isPage && window.loadPage) {
window.loadPage(sgItem.id);
} else if (window.loadGallery) {
window.loadGallery(sgItem.id);
}
let targetUrl = url.href;
if (isPreviewMode && siteGuid) {
const pathname = url.pathname.startsWith(`/${siteGuid}/`) ?
url.pathname :
`/${siteGuid}${url.pathname.startsWith('/') ? url.pathname : '/' + url.pathname}`;
const newUrl = new URL(url);
newUrl.pathname = pathname;
targetUrl = newUrl.href;
}
window.history.pushState(
{ galleryId: sgItem.id },
sgItem.title || document.title,
targetUrl
);
document.title = formatGalleryDocumentTitle(sgItem.title);
window.scrollTo(0, 0);
return;
}
}
// Check if this is internal navigation first
let matchingItem = findGalleryByPath(url.pathname, isPreviewMode, siteGuid);
console.log('Link interceptor - matchingItem found:', !!matchingItem, matchingItem ? matchingItem.title : 'none');
if (matchingItem) {
// Prevent default navigation (full page reload)
e.preventDefault();
e.stopPropagation();
console.log('Found matching gallery for link:', matchingItem.title);
const nestedLinkModeActive = typeof window.isNestedGalleryLinkModeEnabled === 'function'
? window.isNestedGalleryLinkModeEnabled()
: !!(window.neonGalleryConfig?.useLinks && window.neonGalleryConfig?.openMultipleLightboxes);
const isGridGalleryLink = !!(link.closest('.masonry-item') ||
(link.closest('.gallery-wrapper, #neon-gallery-container, .gallery-direct-content, .masonry') &&
link.querySelector('img') &&
!link.closest('#neon-lightbox, .neon-lightbox')));
if (!matchingItem.isPage &&
nestedLinkModeActive &&
isGridGalleryLink &&
typeof window.openNestedGalleryLightbox === 'function') {
void window.openNestedGalleryLightbox(matchingItem, window.activeGalleryId).then((opened) => {
if (!opened) {
console.warn('Nested gallery lightbox failed — staying on parent gallery (no loadGallery fallback)');
}
});
return;
}
// Use the existing navigation functions
if (matchingItem.isPage && window.loadPage) {
window.loadPage(matchingItem.id);
} else if (window.loadGallery) {
window.loadGallery(matchingItem.id);
}
// Construct proper URL for preview mode
let targetUrl = url.href;
if (isPreviewMode && siteGuid) {
// Ensure we keep the GUID in the URL path
const pathname = url.pathname.startsWith(`/${siteGuid}/`) ?
url.pathname :
`/${siteGuid}${url.pathname.startsWith('/') ? url.pathname : '/' + url.pathname}`;
const newUrl = new URL(url);
newUrl.pathname = pathname;
targetUrl = newUrl.href;
}
// Update URL without reload
window.history.pushState(
{ galleryId: matchingItem.id },
matchingItem.title || document.title,
targetUrl
);
// Update page title
document.title = formatGalleryDocumentTitle(matchingItem.title);
// Scroll to top
window.scrollTo(0, 0);
return;
}
// Special case for preview mode - even if no matching gallery found
else if (isPreviewMode && siteGuid) {
// We're in preview mode with a GUID - intercept the navigation
// to maintain the GUID in the URL. Only do this when #galleryFrame
// can load content; otherwise allow SPA onclick handlers to run.
console.log('No direct match but in preview mode - handling navigation');
const frame = document.getElementById('galleryFrame');
if (!frame) {
console.warn('Gallery frame not found - allowing default/SPA handlers');
} else {
// Prevent default navigation
e.preventDefault();
e.stopPropagation();
// Construct a URL that preserves the GUID
let newPathname = url.pathname;
if (!newPathname.startsWith(`/${siteGuid}/`)) {
// Add the GUID to the path if not already there
newPathname = `/${siteGuid}${newPathname.startsWith('/') ? newPathname : '/' + newPathname}`;
}
// Create the preview-friendly URL for browser history
const browserUrl = new URL(window.location.origin);
browserUrl.pathname = newPathname;
console.log('Constructing preview URL with GUID:', browserUrl.href);
// IMPORTANT: For gallery navigation, we need to create a special URL
// that tells the gallery system to load the content directly
console.log('Loading preview path in iframe:', newPathname);
// First update browser URL to show the correct path
window.history.pushState(
{ customPath: newPathname },
document.title,
browserUrl.href
);
// Create direct path to the gallery content
const cleanPath = newPathname.replace(`/${siteGuid}/`, '/');
// For direct gallery paths, we need to load the gallery with special parameters
// This tells the gallery system to load content from this path
const galleryUrl = `https://storage.neonsky.app/sites:site_${siteGuid}.json`;
// Two approaches to loading the content:
// 1. Use the gallery wrapper approach
const wrapperUrl = `https://storage.neonsky.app/standalone/gallery-wrapper.html` +
`?siteId=${siteGuid}` +
`&path=${encodeURIComponent(cleanPath)}` +
`&directPath=true` +
`&t=${Date.now()}`; // Cache buster
console.log('Loading gallery via wrapper URL:', wrapperUrl);
frame.src = wrapperUrl;
// Clear any active gallery ID since we're navigating directly
if (window.activeGalleryId) {
console.log('Clearing active gallery ID during direct navigation');
window.activeGalleryId = null;
}
return; // Critical: prevent further navigation
}
}
console.log('No matching gallery or special handling - allow normal navigation');
} catch (error) {
console.error('Error processing link click:', error);
}
}
// Handle all iframes on the page
function interceptAllIframeLinks() {
// Look for any iframe that might contain gallery content
const iframes = document.querySelectorAll('iframe');
iframes.forEach(iframe => {
if (iframe.id === 'galleryFrame' || iframe.classList.contains('gallery-iframe')) {
// Primary gallery iframe - high priority
setupIframeIntercept(iframe, true);
} else {
// Any other iframe - still try to intercept
setupIframeIntercept(iframe, false);
}
});
console.log(`Attempted to intercept links in ${iframes.length} iframes`);
}
// Setup link interception for a specific iframe
function setupIframeIntercept(iframe, isPrimary) {
try {
// Skip if iframe is not fully loaded yet
if (!iframe.contentWindow || !iframe.contentDocument) {
// Add load event to try again when loaded
iframe.addEventListener('load', function() {
setupIframeIntercept(iframe, isPrimary);
});
return;
}
// Try to access the iframe content - might fail due to same-origin policy
try {
const iframeDoc = iframe.contentDocument;
const iframeWin = iframe.contentWindow;
// Add event listener for all link clicks in the iframe
iframeDoc.addEventListener('click', function(e) {
const link = e.target.closest('a');
if (!link || !link.href) return;
// Skip special cases
if (link.target === '_blank' || e.ctrlKey || e.metaKey || e.shiftKey) return;
if (link.href.includes('#') || link.href.startsWith('javascript:')) return;
try {
const url = new URL(link.href);
// Check if same domain or relative URL
const isSameDomain = url.hostname === window.location.hostname ||
url.hostname === iframe.src.hostname ||
!url.hostname;
if (isSameDomain) {
const isPreviewMode = window.location.hostname === 'preview.neonsky.app';
const siteGuid = getSiteGuidFromPath();
// First try to find a matching gallery/page
const matchingItem = parent.findGalleryByPath ?
parent.findGalleryByPath(url.pathname, isPreviewMode, siteGuid) :
findGalleryByPath(url.pathname, isPreviewMode, siteGuid);
if (matchingItem) {
// We found a match - prevent default and navigate
e.preventDefault();
e.stopPropagation();
console.log('Iframe: Found matching content for link:', matchingItem.title);
// Access parent window functions
if (matchingItem.isPage && parent.loadPage) {
parent.loadPage(matchingItem.id);
} else if (parent.loadGallery) {
parent.loadGallery(matchingItem.id);
}
// Update URL in parent window
let targetUrl = url.href;
if (isPreviewMode && siteGuid) {
// Ensure GUID is preserved in URL
const pathname = url.pathname.startsWith(`/${siteGuid}/`) ?
url.pathname :
`/${siteGuid}${url.pathname.startsWith('/') ? url.pathname : '/' + url.pathname}`;
const newUrl = new URL(window.location.origin + pathname);
targetUrl = newUrl.href;
}
// Update browser URL
parent.history.pushState(
{ galleryId: matchingItem.id },
matchingItem.title || document.title,
targetUrl
);
// Update parent window title
parent.document.title = formatGalleryDocumentTitle(matchingItem.title, parent);
// Scroll parent to top
parent.scrollTo(0, 0);
return;
}
// Special handling for preview mode - critical fix here!
else if (isPreviewMode && siteGuid) {
// Special handling for preview URLs
e.preventDefault();
e.stopPropagation();
console.log('Iframe: No direct match but in preview mode - handling navigation');
// Construct a URL that preserves the GUID
let newPathname = url.pathname;
if (!newPathname.startsWith(`/${siteGuid}/`)) {
newPathname = `/${siteGuid}${newPathname.startsWith('/') ? newPathname : '/' + newPathname}`;
}
// Create a URL for the browser history
const browserUrl = new URL(window.location.origin);
browserUrl.pathname = newPathname;
console.log('Iframe: Constructing preview URL with GUID:', browserUrl.href);
// Update browser URL in parent
parent.history.pushState(
{ customPath: newPathname },
document.title,
browserUrl.href
);
// Handle the navigation in the parent window
if (isPrimary && parent.document.getElementById('galleryFrame')) {
const galleryFrame = parent.document.getElementById('galleryFrame');
// Create clean path for the gallery system
const cleanPath = newPathname.replace(`/${siteGuid}/`, '/');
// Create wrapper URL for gallery content
const wrapperUrl = `https://storage.neonsky.app/standalone/gallery-wrapper.html` +
`?siteId=${siteGuid}` +
`&path=${encodeURIComponent(cleanPath)}` +
`&directPath=true` +
`&t=${Date.now()}`; // Cache buster
console.log('Iframe: Loading gallery via wrapper URL:', wrapperUrl);
galleryFrame.src = wrapperUrl;
// Clear any active gallery ID since we're navigating directly
if (parent.activeGalleryId) {
console.log('Iframe: Clearing active gallery ID during direct navigation');
parent.activeGalleryId = null;
}
} else {
// Fallback - navigate normally in case we can't handle it
console.log('Iframe: Gallery frame not found or not primary, navigating normally');
window.location.href = browserUrl.href;
}
return;
}
}
} catch (iframeError) {
console.error('Error handling iframe link:', iframeError);
}
});
if (isPrimary) {
console.log('Successfully intercepted links in primary gallery iframe');
}
} catch (sameOriginError) {
// This is expected for cross-origin iframes
if (isPrimary) {
console.warn('Cannot access iframe content due to same-origin policy. Using fallback method.');
// Apply a fallback method - add postMessage listener
setupPostMessageListener(iframe);
}
}
} catch (error) {
console.warn('Error setting up iframe link interception:', error);
}
}
// Fallback: use postMessage for cross-origin iframes
function setupPostMessageListener(iframe) {
// Listen for messages from the iframe
window.addEventListener('message', function(event) {
try {
// Verify the message is from our iframe
if (event.source !== iframe.contentWindow) return;
const data = event.data;
// Check if this is a link click event
if (data && data.type === 'linkClick' && data.href) {
console.log('Received link click message from iframe:', data.href);
// Create a URL object to work with the link
const url = new URL(data.href);
// Check if it's a local navigation
if (url.hostname === window.location.hostname) {
const isPreviewMode = window.location.hostname === 'preview.neonsky.app';
const siteGuid = getSiteGuidFromPath();
// Try to find a matching gallery
const matchingItem = findGalleryByPath(url.pathname, isPreviewMode, siteGuid);
if (matchingItem) {
console.log('Found matching gallery for postMessage link:', matchingItem.title);
// Navigate using the appropriate function
if (matchingItem.isPage && window.loadPage) {
window.loadPage(matchingItem.id);
} else if (window.loadGallery) {
window.loadGallery(matchingItem.id);
}
// Update URL without reload
let targetUrl = url.href;
if (isPreviewMode && siteGuid) {
// Ensure GUID is preserved
const pathname = url.pathname.startsWith(`/${siteGuid}/`) ?
url.pathname :
`/${siteGuid}${url.pathname.startsWith('/') ? url.pathname : '/' + url.pathname}`;
const newUrl = new URL(window.location.origin + pathname);
targetUrl = newUrl.href;
}
window.history.pushState(
{ galleryId: matchingItem.id },
matchingItem.title || document.title,
targetUrl
);
// Update page title
document.title = formatGalleryDocumentTitle(matchingItem.title);
return;
}
// Special handling for preview mode - Fixed!
else if (isPreviewMode && siteGuid) {
console.log('No direct match but in preview mode - handling navigation via postMessage');
// Construct URL that preserves the GUID
let newPathname = url.pathname;
if (!newPathname.startsWith(`/${siteGuid}/`)) {
newPathname = `/${siteGuid}${newPathname.startsWith('/') ? newPathname : '/' + newPathname}`;
}
// Create browser URL
const browserUrl = new URL(window.location.origin);
browserUrl.pathname = newPathname;
// Update browser URL
window.history.pushState(
{ customPath: newPathname },
document.title,
browserUrl.href
);
// Get the main gallery frame - NOT the iframe that sent the message
const galleryFrame = document.getElementById('galleryFrame');
if (galleryFrame) {
// Create clean path without GUID
const cleanPath = newPathname.replace(`/${siteGuid}/`, '/');
// Create wrapper URL
const wrapperUrl = `https://storage.neonsky.app/standalone/gallery-wrapper.html` +
`?siteId=${siteGuid}` +
`&path=${encodeURIComponent(cleanPath)}` +
`&directPath=true` +
`&t=${Date.now()}`; // Cache buster
console.log('Loading gallery via wrapper URL (postMessage):', wrapperUrl);
galleryFrame.src = wrapperUrl;
// Clear any active gallery ID
if (window.activeGalleryId) {
window.activeGalleryId = null;
}
return;
} else {
console.warn('Gallery frame not found - cannot load content via postMessage');
}
}
}
}
} catch (error) {
console.error('Error processing postMessage from iframe:', error);
}
});
// Inject a script into the iframe if possible to send messages back
try {
// We might be able to inject a script tag even in cross-origin scenarios
// if the iframe is not yet loaded or using document.write
iframe.addEventListener('load', function() {
try {
const script = iframe.contentDocument.createElement('script');
script.textContent = `
// Link interception script injected from parent
document.addEventListener('click', function(e) {
const link = e.target.closest('a');
if (link && link.href && !link.target && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
// Send message to parent window
window.parent.postMessage({
type: 'linkClick',
href: link.href,
pathname: link.pathname
}, '*');
// Prevent default navigation
e.preventDefault();
}
});
console.log('Gallery link interceptor injected from parent');
`;
iframe.contentDocument.head.appendChild(script);
} catch (e) {
// This will likely fail for cross-origin iframes
console.log('Could not inject script into iframe - expected for cross-origin frames');
}
});
} catch (error) {
console.warn('Error trying to inject script into iframe:', error);
}
}
// Observer for new content/iframes
function setupMutationObserver() {
if (!window.MutationObserver) return;
const observer = new MutationObserver(function(mutations) {
let newIframesFound = false;
mutations.forEach(function(mutation) {
if (mutation.type === 'childList' && mutation.addedNodes.length) {
// Check if any new iframes were added
mutation.addedNodes.forEach(function(node) {
// Check if the node is an iframe or contains iframes
if (node.nodeName === 'IFRAME') {
newIframesFound = true;
setupIframeIntercept(node, node.id === 'galleryFrame');
} else if (node.querySelectorAll) {
const iframes = node.querySelectorAll('iframe');
if (iframes.length > 0) {
newIframesFound = true;
iframes.forEach(function(iframe) {
setupIframeIntercept(iframe, iframe.id === 'galleryFrame');
});
}
}
});
}
});
// If new iframes were found, update our cache
if (newIframesFound) {
refreshGalleryCache();
}
});
// Observe the entire document
observer.observe(document.body, {
childList: true,
subtree: true
});
}
// Helper to check if a URL is a resource rather than a page
function isResourceUrl(path) {
const resourceExtensions = [
'.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg',
'.css', '.js', '.pdf', '.ico', '.ttf', '.woff', '.woff2'
];
return resourceExtensions.some(ext => path.toLowerCase().endsWith(ext));
}
// Get the site GUID from the path in preview mode
function getSiteGuidFromPath() {
if (window.location.hostname === 'preview.neonsky.app') {
const pathParts = window.location.pathname.split('/').filter(Boolean);
if (pathParts.length > 0) {
return pathParts[0];
}
}
return null;
}
/**
* Enhanced gallery path matching with improved accuracy:
*
* This function finds the appropriate gallery based on URL path with better
* prioritization and more precise matching criteria.
*
* @param {string} path - The URL path to match
* @param {boolean} isPreviewMode - Whether we're in preview mode
* @param {string} siteGuid - The site GUID (for preview mode)
* @returns {Object|null} The matching gallery or null if none found
*/
function findGalleryByPath(path, isPreviewMode, siteGuid) {
// Ensure path is a string
if (typeof path !== 'string') {
console.warn('Invalid path provided to findGalleryByPath:', path);
return null;
}
// Determine which galleries array to use - check global variable first
let galleriesData;
let galleryCount = 0;
if (typeof galleries !== 'undefined' && Array.isArray(galleries)) {
galleriesData = galleries;
galleryCount = galleries.length;
} else if (typeof window.galleries !== 'undefined' && Array.isArray(window.galleries)) {
galleriesData = window.galleries;
galleryCount = window.galleries.length;
} else if (window.GALLERY_DATA_CACHE &&
window.GALLERY_DATA_CACHE.initialized &&
Array.isArray(window.GALLERY_DATA_CACHE.galleries)) {
galleriesData = window.GALLERY_DATA_CACHE.galleries;
galleryCount = window.GALLERY_DATA_CACHE.galleries.length;
} else {
console.warn('No galleries array available');
return null;
}
console.log(`Searching for match among ${galleryCount} galleries`);
// Strip leading/trailing slashes without regex — template-literal embedding mangles / escapes
// (e.g. /^/|/$/g becomes /^/|/$/g → 0), which left "/" unnormalized and broke home matching.
function stripEdgeSlashes(value) {
return String(value || '').split('/').filter(Boolean).join('/');
}
// Normalize the path for comparison (handle trailing/leading slashes)
let normalizedPath = stripEdgeSlashes(path).toLowerCase();
const originalNormalizedPath = normalizedPath; // Keep original for logging
// If this is a preview URL, we need to handle the path differently
if (isPreviewMode && siteGuid) {
// Check if the path starts with the GUID
if (normalizedPath.startsWith(siteGuid.toLowerCase() + '/')) {
// Remove the GUID prefix
normalizedPath = normalizedPath.substring(siteGuid.length + 1);
} else if (normalizedPath === siteGuid.toLowerCase()) {
// This is just the GUID with no additional path
normalizedPath = '';
}
}
// Create a dash-normalized version (all sequences of dashes become single dashes)
// This handles both double-dash patterns and any other dash inconsistencies
const normalizedPathWithSingleDashes = normalizedPath.replace(/-+/g, '-');
console.log(`Normalized path: "${normalizedPath}" (dash-normalized: "${normalizedPathWithSingleDashes}")`);
// For empty paths, try to find the homepage
if (!normalizedPath) {
// Look for a gallery marked as home page
const homePage = galleriesData.find(g => g.isHomePage === true);
if (homePage) {
console.log('Empty path - returning homepage:', homePage.title);
return homePage;
}
}
// Array to store potential matches with their confidence levels
const potentialMatches = [];
// Process each gallery to find potential matches with confidence levels
galleriesData.forEach(gallery => {
// Skip invalid galleries
if (!gallery || !gallery.id) return;
// Store matching details for debugging
const matchInfo = {
gallery,
confidence: 0,
matchType: null,
usedNormalizedDashes: false
};
// 1. EXACT SLUG MATCH (highest confidence)
if (gallery.slug) {
const gallerySlug = stripEdgeSlashes(gallery.slug).toLowerCase();
// Try original path
if (gallerySlug === normalizedPath) {
matchInfo.confidence = 100; // Max confidence
matchInfo.matchType = 'exact_slug';
potentialMatches.push({...matchInfo});
return; // Continue to next gallery
}
// Try dash-normalized path
const gallerySlugWithSingleDashes = gallerySlug.replace(/-+/g, '-');
if (normalizedPathWithSingleDashes === gallerySlugWithSingleDashes) {
matchInfo.confidence = 95; // Very high confidence
matchInfo.matchType = 'exact_slug_normalized_dashes';
matchInfo.usedNormalizedDashes = true;
potentialMatches.push({...matchInfo});
return; // Continue to next gallery
}
}
// 2. EXACT URL PATH MATCH (very high confidence)
if (gallery.url) {
let galleryPath = gallery.url.toLowerCase();
if (galleryPath.startsWith('http')) {
try {
const urlObj = new URL(galleryPath);
galleryPath = urlObj.pathname;
} catch (e) {
// If URL parsing fails, use the string as is
}
}
galleryPath = stripEdgeSlashes(galleryPath);
// Try original path
if (galleryPath === normalizedPath) {
matchInfo.confidence = 90; // Very high confidence
matchInfo.matchType = 'exact_url_path';
potentialMatches.push({...matchInfo});
return; // Continue to next gallery
}
// Try dash-normalized path
const galleryPathWithSingleDashes = galleryPath.replace(/-+/g, '-');
if (normalizedPathWithSingleDashes === galleryPathWithSingleDashes) {
matchInfo.confidence = 85; // High confidence
matchInfo.matchType = 'exact_url_path_normalized_dashes';
matchInfo.usedNormalizedDashes = true;
potentialMatches.push({...matchInfo});
return; // Continue to next gallery
}
}
// 3. TITLE SLUG MATCH (high confidence)
if (gallery.title) {
const titleSlug = gallery.title.toLowerCase()
.replace(/s+/g, '-')
.replace(/[^w-]+/g, '')
.replace(/--+/g, '-')
.trim();
// Try original path
if (titleSlug === normalizedPath) {
matchInfo.confidence = 80; // High confidence
matchInfo.matchType = 'title_slug_match';
potentialMatches.push({...matchInfo});
return; // Continue to next gallery
}
// Try dash-normalized path
const titleSlugWithSingleDashes = titleSlug.replace(/-+/g, '-');
if (normalizedPathWithSingleDashes === titleSlugWithSingleDashes) {
matchInfo.confidence = 75; // Good confidence
matchInfo.matchType = 'title_slug_match_normalized_dashes';
matchInfo.usedNormalizedDashes = true;
potentialMatches.push({...matchInfo});
return; // Continue to next gallery
}
}
// 4. PARTIAL SLUG MATCH - BUT WITH MUCH STRICTER CRITERIA
if (gallery.slug && normalizedPath.length > 5) {
const gallerySlug = stripEdgeSlashes(gallery.slug).toLowerCase();
const gallerySlugWithSingleDashes = gallerySlug.replace(/-+/g, '-');
// Only if paths are substantial (to avoid false positives)
if (normalizedPath.length >= 5 && gallerySlug.length >= 5) {
// Try matching significant portions with dash normalization
// If the normalized paths contain each other with substantial overlap
if (normalizedPathWithSingleDashes.includes(gallerySlugWithSingleDashes) ||
gallerySlugWithSingleDashes.includes(normalizedPathWithSingleDashes)) {
// Calculate overlap ratio based on the shorter string
const overlapRatio = Math.min(normalizedPathWithSingleDashes.length, gallerySlugWithSingleDashes.length) /
Math.max(normalizedPathWithSingleDashes.length, gallerySlugWithSingleDashes.length);
// Only consider matches with substantial overlap (over 70%)
if (overlapRatio >= 0.7) {
const score = Math.round(60 + (overlapRatio * 20)); // 60-80 based on overlap
matchInfo.confidence = score;
matchInfo.matchType = 'partial_match_normalized';
matchInfo.usedNormalizedDashes = true;
matchInfo.overlapRatio = overlapRatio;
potentialMatches.push({...matchInfo});
}
}
}
}
});
// No potential matches found
if (potentialMatches.length === 0) {
console.log('No gallery match found for path:', path);
return null;
}
// Sort potential matches by confidence (highest first)
potentialMatches.sort((a, b) => b.confidence - a.confidence);
// Get the best match
const bestMatch = potentialMatches[0];
// Log detailed debugging info about the match
console.log(`Best match for "${normalizedPath}":`, {
title: bestMatch.gallery.title,
id: bestMatch.gallery.id,
matchType: bestMatch.matchType,
confidence: bestMatch.confidence,
usedNormalizedDashes: bestMatch.usedNormalizedDashes,
totalMatches: potentialMatches.length
});
// If we have multiple close matches, log them for debugging
if (potentialMatches.length > 1 &&
potentialMatches[1].confidence >= bestMatch.confidence - 10) {
console.log('Close alternative matches:', potentialMatches.slice(1, 3).map(m => ({
title: m.gallery.title,
id: m.gallery.id,
matchType: m.matchType,
confidence: m.confidence
})));
}
// Return the best matching gallery
return bestMatch.gallery;
}
// Make helpful functions available globally
window.findGalleryByPath = findGalleryByPath;
window.findGalleryById = findGalleryById;
window.refreshGalleryCache = refreshGalleryCache;
window.getSiteGuidFromPath = getSiteGuidFromPath;
if (typeof getSiteGalleriesArray === 'function') {
window.getSiteGalleriesArray = getSiteGalleriesArray;
}
})();
</script>
</div>
<!-- Sidebar element form for adding new elements -->
<div class="sidebar-element-form pe-edit-shell" id="sidebarElementForm" style="display: none;" data-nosnippet>
<p class="pe-edit-shell__title">Add sidebar element</p>
<p class="pe-hint">Select the type of element to add:</p>
<div class="radio-group">
<label>
<input type="radio" name="sidebarElementType" value="text" checked> Text Block
</label>
<label>
<input type="radio" name="sidebarElementType" value="image"> Image
</label>
<label>
<input type="radio" name="sidebarElementType" value="social"> Social Icons
</label>
</div>
<div class="pe-footer-actions">
<button type="button" class="pe-btn pe-btn--primary" onclick="addSidebarElement()">Add element</button>
<button type="button" class="pe-btn" onclick="toggleSidebarElementForm()">Cancel</button>
</div>
</div>
<div class="add-form pe-edit-shell" id="addForm" data-nosnippet>
<p class="pe-edit-shell__title">Add to menu</p>
<input type="text" id="galleryTitle" placeholder="Gallery Title">
<div class="radio-group">
<label>
<input type="radio" name="galleryType" value="integrated" checked> Gallery
</label>
<label>
<input type="radio" name="galleryType" value="page"> Page
</label>
<label>
<input type="radio" name="galleryType" value="folder"> Folder
</label>
<label>
<input type="radio" name="galleryType" value="spacer"> Spacer
</label>
<label>
<input type="radio" name="galleryType" value="external"> External URL
</label>
</div>
<div id="urlInputContainer">
<input type="text" id="galleryUrl" placeholder="Gallery URL">
</div>
<div id="galleryParentContainer" style="display: none;" aria-hidden="true">
<label for="galleryParent">Parent Menu:</label>
<select id="galleryParent">
<option value="">None (Top Level)</option>
</select>
</div>
<div class="pe-footer-actions">
<button type="button" class="pe-btn pe-btn--primary" onclick="addGallery()">Save</button>
</div>
<script>
// Add event listeners to the radio buttons
document.addEventListener('DOMContentLoaded', () => {
const radioButtons = document.querySelectorAll('input[name="galleryType"]');
radioButtons.forEach(radio => {
radio.addEventListener('change', function() {
const urlContainer = document.getElementById('urlInputContainer');
if (this.value === 'external') {
urlContainer.style.display = 'block';
} else {
urlContainer.style.display = 'none';
}
});
});
});
</script>
</div>
<div class="metadata-editor pe-edit-shell" id="metadataEditor" style="display: none; max-height: 90vh; overflow-y: auto;" data-nosnippet>
<h3 class="pe-edit-shell__title">Site Metadata</h3>
<div class="form-group">
<label for="metaTitle">Page Title (Search Results & Browsers)</label>
<input type="text" id="metaTitle" class="form-control" placeholder="Enter site title for search results">
<small>Recommended length: 50-60 characters</small>
</div>
<div class="form-group">
<label for="metaDescription">Meta Description</label>
<textarea id="metaDescription" class="form-control" rows="3" placeholder="Enter site description for search results"></textarea>
<small>Recommended length: 150-160 characters</small>
</div>
<div class="form-group">
<label for="ogImageUrl">Social preview image (Open Graph / X / iMessage)</label>
<input type="hidden" id="ogImageUrl" class="form-control" value="">
<button type="button" id="uploadOgImageBtn" class="pe-btn upload-image-btn">
<svg style="width: 16px; height: 16px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
<span id="uploadOgImageBtnText">Upload image</span>
</button>
<div id="ogImagePreviewContainer"></div>
<small>Recommended about 1200×630px. Optional: when you upload an image here, it is used for link previews site-wide. If you leave this empty, the first image on each page or gallery (including the home page) is used instead.</small>
</div>
<div class="form-group">
<label for="googleAnalytics">Google Analytics Code</label>
<input type="text" id="googleAnalytics" class="form-control" placeholder="Enter GA-XXXXXXXXXX">
<small>Example: G-XXXXXXXXXX or UA-XXXXXXXX-X</small>
</div>
<div class="form-group">
<label for="facebookPixel">Meta (Facebook) Pixel ID</label>
<input type="text" id="facebookPixel" class="form-control" inputmode="numeric" placeholder="Enter your numeric Pixel ID">
<small>Enter the Pixel ID from Meta Events Manager. A full Meta Pixel snippet can also be pasted.</small>
</div>
<div class="form-group">
<label for="contentLanguage">Content Language</label>
<select id="contentLanguage" class="form-control">
<option value="en">English (en)</option>
<option value="es">Spanish / Español (es)</option>
<option value="fr">French / Français (fr)</option>
<option value="ne">Nepali / नेपाली (ne)</option>
<option value="de">German / Deutsch (de)</option>
<option value="it">Italian / Italiano (it)</option>
<option value="pt">Portuguese / Português (pt)</option>
<option value="ru">Russian / Русский (ru)</option>
<option value="zh">Chinese / 中文 (zh)</option>
<option value="ja">Japanese / 日本語 (ja)</option>
<option value="ko">Korean / 한국어 (ko)</option>
<option value="ar">Arabic / العربية (ar)</option>
<option value="hi">Hindi / हिन्दी (hi)</option>
</select>
<small>Set the primary language of your site content. Chrome will offer to translate if visitor's language is different.</small>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="noIndexToggle">
Hide site from search engines (noindex)
</label>
<small>Use this if the site is under development or shouldn't appear in search results</small>
</div>
<div class="form-group" style="border-top: 1px solid #ddd; padding-top: 16px; margin-top: 16px;">
<h4 style="margin-bottom: 8px;">Schema / Identity</h4>
<p style="font-size: 13px; color: #555; margin-bottom: 12px;">Helps Google understand what this site represents — used in Knowledge Panels and local search results.</p>
<label style="display: block; margin-bottom: 6px;">This site represents:</label>
<div style="display: flex; gap: 20px; margin-bottom: 4px;">
<label style="display: flex; align-items: center; gap: 6px; font-weight: normal;">
<input type="radio" name="schemaType" id="schemaTypePerson" value="Person" onchange="updateSchemaFields()"> Person / Individual
</label>
<label style="display: flex; align-items: center; gap: 6px; font-weight: normal;">
<input type="radio" name="schemaType" id="schemaTypeOrganization" value="Organization" onchange="updateSchemaFields()"> Organization / Business
</label>
</div>
<small>Select "Person" for individual photographers, artists, consultants. Select "Organization" for studios, agencies, companies.</small>
</div>
<div class="form-group" id="schemaJobTitleGroup" style="display: none;">
<label for="schemaJobTitle">Job Title</label>
<input type="text" id="schemaJobTitle" class="form-control" placeholder="">
<small>Used in Google Knowledge Panels and local search (e.g. "photojournalist Minneapolis").</small>
</div>
<div class="form-group" id="schemaCityGroup" style="display: none;">
<label for="schemaCity">City / Location</label>
<input type="text" id="schemaCity" class="form-control" placeholder="">
<small>Improves visibility in location-based searches.</small>
</div>
<div class="form-group" id="schemaEmailGroup" style="display: none;">
<label for="schemaEmail">Contact Email (for structured data)</label>
<input type="text" id="schemaEmail" class="form-control" placeholder="e.g. hello@example.com">
<small>Embedded in structured data only — not displayed on the page.</small>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="metadataCopyrightEnabled">
Enable sidebar copyright footer
</label>
<small>When enabled, the copyright text shows at the bottom of the sidebar.</small>
</div>
<div class="form-group" id="metadataCopyrightTextGroup">
<label for="metadataCopyrightText">Copyright Text</label>
<input type="text" id="metadataCopyrightText" class="form-control" placeholder="© 2025 Your Name">
<small>This text appears in the sidebar footer and inside the copyright overlay.</small>
</div>
<div class="form-group">
<label for="faviconUrl">Favicon</label>
<input type="hidden" id="faviconUrl" class="form-control">
<button type="button" id="uploadFaviconBtn" class="pe-btn upload-image-btn">
<svg style="width: 16px; height: 16px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
<span id="uploadFaviconBtnText">Upload Favicon</span>
</button>
<div id="faviconPreviewContainer"></div>
<small>Upload an image file for your site favicon (recommended: 32x32px or 16x16px, PNG or ICO format)</small>
</div>
<div class="form-group" style="border-top: 1px solid #ddd; padding-top: 16px; margin-top: 16px;">
<h4 style="margin-bottom: 12px;">Custom HTML site (full bypass)</h4>
<p style="font-size: 13px; color: #555; margin-bottom: 12px;">
Upload one self-contained HTML file (e.g. from an AI tool). When enabled, visitors to the site root see only that file — not the Hydra menu or gallery.
To log in and edit, open <strong>/_manage</strong> on your domain, then press <strong>+</strong> (numpad + or Shift+= on US keyboards) to log in or toggle edit mode, then open Site Metadata from the edit bar.
To log out in this browser, visit <strong>/_manage?logout=1</strong> or <strong>/_manage/logout</strong> (also works as <code>?hydra_logout=1</code> on any page).
</p>
<label>
<input type="checkbox" id="customPublicSiteEnabled" />
Serve uploaded HTML as the public site (bypass Hydra shell on <code>/</code>)
</label>
<input type="hidden" id="customPublicSiteHtmlR2Key" value="" />
<input type="hidden" id="customPublicSiteHtmlFilenameStore" value="" />
<div class="form-group" style="margin-top: 10px;">
<button type="button" id="uploadCustomPublicHtmlBtn" class="pe-btn">Upload HTML file</button>
<button type="button" id="removeCustomPublicHtmlBtn" class="pe-btn">Clear file</button>
</div>
<p id="customPublicSiteHtmlFilenameLabel" style="font-size: 13px; color: #666; margin-top: 8px;">No HTML file uploaded yet.</p>
</div>
<div class="form-group" style="border-top: 1px solid #ddd; padding-top: 16px; margin-top: 16px;">
<h4 style="margin-bottom: 8px;">Site-wide password (browser login)</h4>
<p style="font-size: 13px; color: #555; margin-bottom: 12px;">
When enabled, visitors must enter this username and password in the browser’s dialog before any page or asset loads (same credentials on your live domain and on
<code>preview.neonsky.app/<your-site-id>/</code>). Editors still use Magic login to edit. Turn off to make the site fully public again.
</p>
<label>
<input type="checkbox" id="siteWideBasicAuthEnabled" onchange="updateSiteWideBasicAuthFieldState()" />
Require HTTP Basic password for the entire site
</label>
<div id="siteWideBasicAuthFields" style="display: none; margin-top: 12px;">
<div class="form-group">
<label for="siteWideBasicAuthUsername">Username</label>
<input type="text" id="siteWideBasicAuthUsername" class="form-control" autocomplete="username" />
</div>
<div class="form-group">
<label for="siteWideBasicAuthPassword">Password</label>
<input type="password" id="siteWideBasicAuthPassword" class="form-control" autocomplete="new-password" />
<small>Stored in your site configuration. Use a strong password; anyone with it can view the site.</small>
</div>
<div class="form-group">
<label for="siteWideBasicAuthRealm">Realm (optional)</label>
<input type="text" id="siteWideBasicAuthRealm" class="form-control" placeholder="Site" />
<small>Label shown in the browser login dialog.</small>
</div>
</div>
</div>
<div class="form-actions pe-footer-actions">
<button type="button" class="pe-btn pe-btn--primary" onclick="saveMetadata()">Save Metadata</button>
<button type="button" class="pe-btn" onclick="toggleMetadataEditor()">Cancel</button>
</div>
</div>
<script>
function updateSiteWideBasicAuthFieldState() {
const en = document.getElementById('siteWideBasicAuthEnabled');
const fields = document.getElementById('siteWideBasicAuthFields');
if (fields) fields.style.display = en && en.checked ? 'block' : 'none';
}
function updateSchemaFields() {
const isPerson = document.getElementById('schemaTypePerson') && document.getElementById('schemaTypePerson').checked;
const isOrg = document.getElementById('schemaTypeOrganization') && document.getElementById('schemaTypeOrganization').checked;
const anySelected = isPerson || isOrg;
const jobTitleGroup = document.getElementById('schemaJobTitleGroup');
const cityGroup = document.getElementById('schemaCityGroup');
const emailGroup = document.getElementById('schemaEmailGroup');
if (jobTitleGroup) jobTitleGroup.style.display = isPerson ? 'block' : 'none';
if (cityGroup) cityGroup.style.display = anySelected ? 'block' : 'none';
if (emailGroup) emailGroup.style.display = anySelected ? 'block' : 'none';
}
function uploadCustomPublicHtmlFile() {
const siteId = window.Parameters && window.Parameters.siteId ? window.Parameters.siteId : (window.siteId || '');
if (!siteId) {
alert('Site ID not found. Refresh the page and try again.');
return;
}
const authToken = window.ImageUploader && window.ImageUploader.getAuthToken ? window.ImageUploader.getAuthToken() : null;
if (!authToken) {
alert('Please log in first (press + on the page).');
return;
}
const input = document.createElement('input');
input.type = 'file';
input.accept = '.html,.htm,text/html';
input.addEventListener('change', function() {
const file = input.files && input.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
formData.append('guid', siteId);
formData.append('purpose', 'custom-public-site');
formData.append('pageId', '_site');
formData.append('elementId', 'custom-public-html');
formData.append('folderPath', '');
var relUpload = typeof window.getApiUrl === 'function' ? window.getApiUrl('/api/upload-file') : '/api/upload-file';
var uploadUrl = window.location && window.location.origin ? new URL(relUpload, window.location.origin).href : relUpload;
fetch(uploadUrl, {
method: 'POST',
headers: { 'Authorization': authToken, 'X-User-Email': localStorage.getItem('hydra_auth_email') || '' },
body: formData
}).then(function(r) {
return r.text().then(function(text) {
var ct = (r.headers.get('content-type') || '').toLowerCase();
if (ct.indexOf('application/json') === -1 && /<!DOCTYPE/i.test(text)) {
throw new Error(
'Server returned HTML instead of JSON — POST /api/upload-file is not reaching the Hydra worker. ' +
'In Cloudflare, ensure this hostname has a Worker route (e.g. neonsky.app/*) so API traffic is not sent to another host or Pages.'
);
}
if (r.status === 403 && ct.indexOf('application/json') === -1) {
throw new Error(
'Upload blocked (403). If this is an HTML file on preview or your domain, Cloudflare WAF may be blocking the request — add a custom rule to skip WAF for POST URI path containing /api/upload-file.'
);
}
try {
return JSON.parse(text);
} catch (parseErr) {
throw new Error('Server did not return JSON: ' + text.slice(0, 120) + (text.length > 120 ? '...' : ''));
}
});
}).then(function(res) {
if (res.success && res.key) {
var keyEl = document.getElementById('customPublicSiteHtmlR2Key');
var fnEl = document.getElementById('customPublicSiteHtmlFilenameStore');
var lbl = document.getElementById('customPublicSiteHtmlFilenameLabel');
if (keyEl) keyEl.value = res.key;
if (fnEl) fnEl.value = file.name;
if (lbl) lbl.textContent = 'Current file: ' + file.name + ' — click Save Metadata below to apply.';
window.siteMetadata = window.siteMetadata || {};
window.siteMetadata.customPublicSiteHtmlR2Key = res.key;
window.siteMetadata.customPublicSiteHtmlFilename = file.name;
} else {
alert(res.error || res.details || 'Upload failed');
}
}).catch(function(e) {
alert('Upload failed: ' + (e && e.message ? e.message : 'Unknown error'));
});
});
input.click();
}
function clearCustomPublicHtmlFile() {
if (!confirm('Clear the uploaded HTML file reference? (You can upload again later.)')) return;
var k = document.getElementById('customPublicSiteHtmlR2Key');
var fn = document.getElementById('customPublicSiteHtmlFilenameStore');
var lbl = document.getElementById('customPublicSiteHtmlFilenameLabel');
if (k) k.value = '';
if (fn) fn.value = '';
if (lbl) lbl.textContent = 'No HTML file uploaded yet.';
if (window.siteMetadata) {
window.siteMetadata.customPublicSiteHtmlR2Key = '';
window.siteMetadata.customPublicSiteHtmlFilename = '';
}
}
// Handle favicon upload button click
document.addEventListener('DOMContentLoaded', function() {
const uploadBtn = document.getElementById('uploadFaviconBtn');
if (uploadBtn) {
uploadBtn.addEventListener('click', function() {
uploadFavicon();
});
}
const uploadOgBtn = document.getElementById('uploadOgImageBtn');
if (uploadOgBtn) {
uploadOgBtn.addEventListener('click', function() {
uploadSiteOgImage();
});
}
var upHtml = document.getElementById('uploadCustomPublicHtmlBtn');
if (upHtml) upHtml.addEventListener('click', uploadCustomPublicHtmlFile);
var rmHtml = document.getElementById('removeCustomPublicHtmlBtn');
if (rmHtml) rmHtml.addEventListener('click', clearCustomPublicHtmlFile);
// Handle content language dropdown change
const contentLanguageSelect = document.getElementById('contentLanguage');
if (contentLanguageSelect) {
contentLanguageSelect.addEventListener('change', function() {
console.warn('Content language changed to:', this.value);
// Update global variable immediately
window.siteMetadata.contentLanguage = this.value;
console.warn('Updated window.siteMetadata.contentLanguage:', window.siteMetadata.contentLanguage);
});
}
const copyrightToggle = document.getElementById('metadataCopyrightEnabled');
if (copyrightToggle) {
copyrightToggle.addEventListener('change', updateMetadataCopyrightFieldState);
}
updateMetadataCopyrightFieldState();
});
// Function to handle favicon upload using ImageUploader
function uploadFavicon() {
console.log('Starting favicon upload...');
// Check if ImageUploader is available
if (!window.ImageUploader || typeof window.ImageUploader.open !== 'function') {
alert('Image uploader not available. Please enter a favicon URL manually.');
return;
}
try {
// Create options for the ImageUploader
const options = {
title: 'Upload Favicon',
containerType: 'favicon',
maxWidth: 64, // Favicons are typically small
maxHeight: 64,
quality: 0.9
};
// Call the ImageUploader
window.ImageUploader.open(options, function(imageResult, imageInfo) {
console.log('Favicon upload callback received:', imageInfo);
// Get the favicon URL input field
let faviconInput = document.getElementById('faviconUrl');
console.warn('=== UPLOAD CALLBACK DEBUG ===');
console.warn('Favicon input element found:', !!faviconInput);
console.warn('Favicon input element type:', faviconInput ? faviconInput.type : 'N/A');
console.warn('Favicon input element id:', faviconInput ? faviconInput.id : 'N/A');
// If element not found, wait a bit and try again (metadata editor might not be open yet)
if (!faviconInput) {
console.warn('Favicon input not found, waiting 100ms and trying again...');
setTimeout(() => {
faviconInput = document.getElementById('faviconUrl');
console.warn('Retry - Favicon input element found:', !!faviconInput);
if (faviconInput) {
console.warn('Retry - Setting favicon input value to:', imageInfo.url);
faviconInput.value = imageInfo.url;
console.warn('Retry - Favicon input value after setting:', faviconInput.value);
} else {
console.error('Retry - Favicon input field still not found');
}
}, 100);
return;
}
// Set the uploaded image URL
if (imageInfo && imageInfo.type === 'r2' && imageInfo.url) {
// R2 storage - use the URL
console.warn('Setting favicon input value to:', imageInfo.url);
faviconInput.value = imageInfo.url;
console.warn('Favicon input value after setting:', faviconInput.value);
// Also store in global variable as backup
window.uploadedFaviconUrl = imageInfo.url;
console.warn('Stored favicon URL in global variable:', window.uploadedFaviconUrl);
console.log('Favicon URL set to:', imageInfo.url);
// Update button text
const btnText = document.getElementById('uploadFaviconBtnText');
if (btnText) {
btnText.textContent = 'Change Favicon';
}
// Show preview
showFaviconPreview(imageInfo.url);
} else if (imageResult) {
// Legacy base64 storage
faviconInput.value = imageResult;
console.log('Favicon set to base64 data');
// Update button text
const btnText = document.getElementById('uploadFaviconBtnText');
if (btnText) {
btnText.textContent = 'Change Favicon';
}
// Show preview
showFaviconPreview(imageResult);
} else {
console.error('No image data received from uploader');
alert('Upload failed. Please try again.');
return;
}
// Show success message
console.log('Favicon uploaded successfully');
});
} catch (error) {
console.error('Error opening favicon uploader:', error);
alert('Error opening uploader: ' + (error.message || 'Unknown error'));
}
}
// Function to show a preview of the uploaded favicon
function showFaviconPreview(imageUrl) {
const container = document.getElementById('faviconPreviewContainer');
if (!container) {
console.error('Favicon preview container not found');
return;
}
container.innerHTML =
'<div style="margin-top: 15px; padding: 15px; border: 1px solid #ddd; border-radius: 4px; background: #f9f9f9; display: flex; align-items: center; gap: 15px;">' +
'<img src="' + imageUrl + '" alt="Favicon Preview" style="width: 32px; height: 32px; border: 1px solid #ccc; background: white; padding: 4px;">' +
'<div style="flex: 1;">' +
'<div style="font-size: 14px; color: #333; font-weight: 500;">Current Favicon</div>' +
'<div style="font-size: 12px; color: #666; margin-top: 2px;">Preview (32x32px)</div>' +
'</div>' +
'<button type="button" onclick="removeFavicon()" style="background: #dc3545; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;">' +
'Remove' +
'</button>' +
'</div>';
}
// Function to remove the favicon
function removeFavicon() {
const faviconInput = document.getElementById('faviconUrl');
if (faviconInput) {
faviconInput.value = '';
}
const container = document.getElementById('faviconPreviewContainer');
if (container) {
container.innerHTML = '';
}
const btnText = document.getElementById('uploadFaviconBtnText');
if (btnText) {
btnText.textContent = 'Upload Favicon';
}
console.log('Favicon removed');
}
function uploadSiteOgImage() {
if (!window.ImageUploader || typeof window.ImageUploader.open !== 'function') {
alert('Image uploader not available. Refresh the page and try again.');
return;
}
try {
window.ImageUploader.open({
title: 'Upload social preview image',
containerType: 'page',
quality: 0.92
}, function(imageResult, imageInfo) {
const inp = document.getElementById('ogImageUrl');
const setUrl = function(url) {
if (inp) inp.value = url;
const btnText = document.getElementById('uploadOgImageBtnText');
if (btnText) btnText.textContent = 'Change image';
showOgImagePreview(url);
window.siteMetadata = window.siteMetadata || {};
window.siteMetadata.ogImageUrl = url;
};
if (imageInfo && imageInfo.url) {
setUrl(imageInfo.url);
} else if (imageResult && typeof imageResult === 'string' && imageResult.indexOf('data:') === 0) {
setUrl(imageResult);
} else {
alert('Upload failed. Please try again.');
}
});
} catch (error) {
console.error('Error opening OG image uploader:', error);
alert('Error opening uploader: ' + (error.message || 'Unknown error'));
}
}
function showOgImagePreview(imageUrl) {
const container = document.getElementById('ogImagePreviewContainer');
if (!container) return;
container.innerHTML =
'<div style="margin-top: 15px; padding: 15px; border: 1px solid #ddd; border-radius: 4px; background: #f9f9f9; display: flex; align-items: center; gap: 15px;">' +
'<img src="' + imageUrl + '" alt="Social preview" style="max-width: 200px; max-height: 105px; width: auto; height: auto; border: 1px solid #ccc; object-fit: contain; background: white;">' +
'<div style="flex: 1;">' +
'<div style="font-size: 14px; color: #333; font-weight: 500;">Current social preview image</div>' +
'<div style="font-size: 12px; color: #666; margin-top: 2px;">This image overrides the first page/gallery image for social previews</div>' +
'</div>' +
'<button type="button" onclick="removeOgImage()" style="background: #dc3545; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;">' +
'Remove' +
'</button>' +
'</div>';
}
function removeOgImage() {
const inp = document.getElementById('ogImageUrl');
if (inp) inp.value = '';
if (window.siteMetadata) window.siteMetadata.ogImageUrl = '';
const container = document.getElementById('ogImagePreviewContainer');
if (container) container.innerHTML = '';
const btnText = document.getElementById('uploadOgImageBtnText');
if (btnText) btnText.textContent = 'Upload image';
}
</script>
<!-- Import Classic Form -->
<div class="import-classic-form" id="importClassicForm" style="display: none;" data-nosnippet>
<h3>Import Galleries from JSON or Classic GUID</h3>
<p>Paste the full site JSON into the text area below, <strong>OR</strong> enter the GUID of a classic gallery collection.</p>
<div class="form-group">
<label for="pastedJson">Pasted Site JSON:</label>
<textarea id="pastedJson" rows="6" placeholder="Paste the entire site JSON here..."></textarea>
</div>
<div class="import-separator">OR</div>
<div class="form-group">
<label for="classicGuid">Classic GUID or Import ID:</label>
<input type="text" id="classicGuid" placeholder="Enter GUID (e.g., 4bd5ebf1b1418) or import ID (e.g., import_4c86452fb530b)">
<small>Use a GUID to fetch from classic site, or use import_ format to fetch from R2 storage.</small>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="replaceMenuCheckbox" onchange="document.getElementById('importParent').disabled = this.checked; document.getElementById('createSubmenu').disabled = this.checked;">
Replace existing menu structure
</label>
<small>If checked, the current menu will be deleted and replaced with the imported items. If unchecked, imported items will be added to the current menu.</small>
</div>
<div class="form-group">
<label for="importParent">Import under:</label>
<select id="importParent">
<option value="">Top Level (No Parent)</option>
</select>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="createSubmenu" checked>
Create submenu for all imported galleries
</label>
</div>
<div class="form-group" id="submenuTitleContainer">
<label for="submenuTitle">Submenu title:</label>
<input type="text" id="submenuTitle" placeholder="e.g., Classic Galleries">
</div>
<div id="importStatus" class="import-status" style="display: none;">
<div class="import-progress"><div class="progress-bar"></div></div>
<div class="status-message">Processing...</div>
</div>
<div class="form-actions">
<button class="btn btn-primary" onclick="importClassicGalleries()">Import from Paste/GUID</button>
<button class="btn" onclick="downloadCurrentSiteJSON()">Download Current Site JSON</button>
<button class="btn" onclick="toggleImportClassicForm()">Cancel</button>
</div>
</div>
<div class="tree-container">
<div id="galleryTree" data-help-id="menu-tree"></div>
</div>
<!-- Sidebar Elements Container will be inserted here by the sidebar-manager.js -->
<div class="sidebar-elements-container"></div>
</div>
<div class="gallery-container" id="hydra-main-content" role="main" tabindex="-1">
<iframe class="gallery-iframe" id="galleryFrame" title="jamespomerantz.com"></iframe>
</div>
</div>
<!-- Cookie Notice (compliance mode — only when Display Cookie Notice is enabled) -->
<script>
// Reveal content once layout is applied, CSS is loaded, AND gallery is initialized
(function() {
let hasRevealed = false; // Ensure we only reveal once per page load
function revealContent() {
if (hasRevealed) return; // Prevent multiple calls
hasRevealed = true;
document.documentElement.classList.add('hydra-ready');
document.body.classList.add('hydra-ready');
console.log('✅ Content revealed - hydra-ready class added');
}
// Check if this page has a gallery container
function hasGalleryContainer() {
return !!document.getElementById('neon-gallery-container') ||
!!document.querySelector('.gallery-wrapper') ||
!!document.querySelector('.masonry-container');
}
// Check if gallery is already initialized
function isGalleryInitialized() {
return window.neonGalleryState?.initialized ||
window.neonGalleryState?.domInitialized ||
(window.neonGalleryUtils && window.neonGalleryUtils.isInitialized && window.neonGalleryUtils.isInitialized());
}
// Main reveal logic
function attemptReveal() {
const hasGallery = hasGalleryContainer();
const galleryInitialized = isGalleryInitialized();
// If no gallery, reveal immediately after CSS loads
if (!hasGallery) {
if (document.readyState === 'complete') {
revealContent();
} else {
window.addEventListener('load', revealContent, { once: true });
}
return;
}
// If gallery exists and is initialized, reveal
if (hasGallery && galleryInitialized) {
revealContent();
return;
}
// If gallery exists but not initialized, wait for initialization
if (hasGallery && !galleryInitialized) {
console.log('⏳ Waiting for gallery initialization before revealing content...');
// Listen for gallery-loaded event
window.addEventListener('gallery-loaded', function() {
console.log('✅ Gallery-loaded event received');
setTimeout(revealContent, 100); // Small delay to ensure DOM is updated
}, { once: true });
// Listen for page-loaded event
window.addEventListener('page-loaded', function() {
console.log('✅ Page-loaded event received');
setTimeout(revealContent, 100);
}, { once: true });
// Fallback: Poll for gallery initialization (max 10 seconds)
let pollAttempts = 0;
const maxPollAttempts = 100; // 10 seconds (100 * 100ms)
const pollInterval = setInterval(function() {
pollAttempts++;
if (isGalleryInitialized()) {
console.log('✅ Gallery initialized detected via polling');
clearInterval(pollInterval);
revealContent();
} else if (pollAttempts >= maxPollAttempts) {
console.warn('⚠️ Gallery initialization timeout - revealing content anyway');
clearInterval(pollInterval);
revealContent(); // Reveal anyway to prevent blank screen
}
}, 100);
// Safety fallback: Always reveal after 8 seconds max (prevents permanent blank screen)
setTimeout(function() {
if (!hasRevealed) {
console.warn('⚠️ Safety timeout reached - revealing content to prevent blank screen');
revealContent();
}
}, 8000);
}
}
// Start the reveal process
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
// Wait a bit for scripts to initialize
setTimeout(attemptReveal, 100);
});
} else if (document.readyState === 'interactive' || document.readyState === 'complete') {
// DOM already ready, wait a bit for scripts
setTimeout(attemptReveal, 100);
} else {
// Fallback
window.addEventListener('load', attemptReveal, { once: true });
}
})();
</script>
<script>
// ============================================
// LANGUAGE TRANSLATION SYSTEM
// ============================================
(function() {
// CRITICAL: Only initialize translation system if a languages element exists
// This ensures sites without languages element have ZERO overhead
const checkForLanguagesElement = function() {
// Wait for SidebarManager to be initialized
if (!window.SidebarManager || !window.SidebarManager.elements) {
return false;
}
// Check if any languages element exists
const hasLanguagesElement = window.SidebarManager.elements.some(el => el.type === 'languages');
if (!hasLanguagesElement) {
console.warn('No languages element found - translation system disabled');
return false;
}
return true;
};
// Check for languages element, retry if SidebarManager not ready yet
let initAttempts = 0;
const maxAttempts = 10;
function tryInitialize() {
if (checkForLanguagesElement()) {
console.warn('✅ Languages element found - initializing translation system');
initializeTranslationSystem();
} else {
initAttempts++;
if (initAttempts < maxAttempts) {
// Retry in 500ms (total max wait: 5 seconds)
setTimeout(tryInitialize, 500);
} else {
console.warn('Translation system not needed for this site - no languages element found after 5 seconds');
}
}
}
// Start checking after initial delay
setTimeout(tryInitialize, 500);
function initializeTranslationSystem() {
console.warn('✅ Translation system initialized successfully');
let currentLanguage = localStorage.getItem('hydra_language') || 'en';
const originalTexts = new Map();
// Expose test function for debugging
window.testTranslation = function(langCode) {
console.warn('🧪 Manual translation test triggered for:', langCode);
console.warn('🔍 Dispatching test event...');
window.dispatchEvent(new CustomEvent('hydra:language-change', {
detail: { code: langCode || 'es', name: 'Test' }
}));
console.warn('✅ Test event dispatched - check if listener caught it above');
};
console.warn('✅ Event listener for "hydra:language-change" is now active');
// Watch for edit mode changes and restore default language
const editModeObserver = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.attributeName === 'class') {
const isEditMode = document.body.classList.contains('edit-mode-active');
if (isEditMode) {
console.warn('Edit mode activated - restoring default language');
// Restore original text
restoreOriginalText();
// Update language buttons to show default language
const languagesElement = window.SidebarManager?.elements?.find(el => el.type === 'languages');
const defaultLang = languagesElement?.languages?.find(l => l.isDefault)?.code || 'en';
document.querySelectorAll('.language-button-live').forEach(btn => {
if (btn.getAttribute('data-lang-code') === defaultLang) {
btn.style.background = 'var(--menu-active-color, #4682B4)';
} else {
btn.style.background = 'transparent';
}
});
}
}
});
});
// Start observing body for class changes
if (document.body) {
editModeObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] });
}
// Watch for dynamically loaded content (galleries, pages, etc.) and translate it
const contentObserver = new MutationObserver(function(mutations) {
// Only process if we're not in default language and not in edit mode
const languagesElement = window.SidebarManager?.elements?.find(el => el.type === 'languages');
const defaultLang = languagesElement?.languages?.find(l => l.isDefault)?.code || 'en';
const isEditMode = document.body.classList.contains('edit-mode-active');
if (currentLanguage === defaultLang || isEditMode) {
return; // Don't translate if in default language or edit mode
}
// Check if new text content was added
let hasNewTextContent = false;
mutations.forEach(function(mutation) {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
mutation.addedNodes.forEach(function(node) {
if (node.nodeType === Node.ELEMENT_NODE) {
// Check if the added element contains text we should translate
const textElements = node.querySelectorAll('p, h1, h2, h3, .page-text');
if (textElements.length > 0 || (node.textContent && node.textContent.trim().length > 0)) {
hasNewTextContent = true;
}
}
});
}
});
if (hasNewTextContent) {
console.warn('New content detected - translating to:', currentLanguage);
// Debounce to avoid translating multiple times rapidly
clearTimeout(window._translationDebounce);
window._translationDebounce = setTimeout(function() {
translatePage(currentLanguage, defaultLang);
}, 300);
}
});
// Observe the gallery container and page content areas for new content
const galleryContainer = document.querySelector('.gallery-container');
if (galleryContainer) {
contentObserver.observe(galleryContainer, {
childList: true,
subtree: true
});
}
// Also observe the main content area
const mainContent = document.querySelector('.container');
if (mainContent) {
contentObserver.observe(mainContent, {
childList: true,
subtree: true
});
}
// Listen for language change events
window.addEventListener('hydra:language-change', async function(e) {
const { code, name } = e.detail;
console.warn('🌐 Language changed to:', code, name);
// CRITICAL: Ignore language changes in edit mode
if (document.body.classList.contains('edit-mode-active')) {
console.warn('⚠️ Edit mode active - ignoring language change');
// Reset to default language
const languagesElement = window.SidebarManager?.elements?.find(el => el.type === 'languages');
const defaultLang = languagesElement?.languages?.find(l => l.isDefault)?.code || 'en';
// Update button states to show default language
document.querySelectorAll('.language-button-live').forEach(btn => {
if (btn.getAttribute('data-lang-code') === defaultLang) {
btn.style.background = 'var(--menu-active-color, #4682B4)';
} else {
btn.style.background = 'transparent';
}
});
// Show message to user
console.warn('Language switching is disabled in edit mode');
return;
}
// Update all language buttons
document.querySelectorAll('.language-button-live').forEach(btn => {
if (btn.getAttribute('data-lang-code') === code) {
btn.style.background = 'var(--menu-active-color, #4682B4)';
} else {
btn.style.background = 'transparent';
}
});
// Save preference
localStorage.setItem('hydra_language', code);
currentLanguage = code;
// Get default language from languages element
const languagesElement = window.SidebarManager?.elements?.find(el => el.type === 'languages');
const defaultLang = languagesElement?.languages?.find(l => l.isDefault)?.code || 'en';
if (code === defaultLang) {
// Restore original language
restoreOriginalText();
} else {
// Translate page
await translatePage(code, defaultLang);
}
});
async function translatePage(targetLang, sourceLang) {
console.warn(`🔄 Translating page from ${sourceLang} to ${targetLang}`);
// Elements to translate
const selectors = [
'.sidebar li a:not(.edit-mode-active .sidebar li a)', // Menu items (not in edit mode)
'.page-text:not(.edit-mode-active .page-text)', // Page content (not in edit mode)
'h1:not(.edit-mode-active h1)',
'h2:not(.edit-mode-active h2)',
'h3:not(.edit-mode-active h3)',
'p:not(.edit-mode-active p)'
];
const elements = document.querySelectorAll(selectors.join(', '));
for (const element of elements) {
// Skip if already translated to this language
if (element.dataset.translatedTo === targetLang) continue;
// Skip if element is empty
const text = element.textContent.trim();
if (!text || text.length === 0) continue;
// Store original text
if (!originalTexts.has(element)) {
originalTexts.set(element, text);
}
// Check translation cache
const cacheKey = `translate_${text}_${targetLang}`;
let translated = getFromCache(cacheKey);
if (translated) {
element.textContent = translated;
element.dataset.translatedTo = targetLang;
} else {
// Translate
try {
translated = await translate(text, sourceLang, targetLang);
if (translated && translated !== text) {
element.textContent = translated;
element.dataset.translatedTo = targetLang;
saveToCache(cacheKey, translated);
}
} catch (error) {
console.error('Translation error:', error);
}
}
}
}
function restoreOriginalText() {
originalTexts.forEach((originalText, element) => {
if (element && element.isConnected) {
element.textContent = originalText;
delete element.dataset.translatedTo;
}
});
}
async function translate(text, sourceLang, targetLang) {
if (!text || text.trim().length === 0) return text;
// Try Browser Translation API (Free, Chrome 130+)
if ('translation' in self && 'createTranslator' in self.translation) {
try {
console.warn('🔄 Using Browser Translation API');
const translator = await self.translation.createTranslator({
sourceLanguage: sourceLang,
targetLanguage: targetLang
});
const result = await translator.translate(text);
console.warn('✅ Translation successful via Browser API');
return result;
} catch (error) {
console.warn('❌ Browser translation failed:', error);
}
} else {
console.warn('⚠️ Browser Translation API not available');
console.warn(' To enable in Chrome: chrome://flags/#translation-api');
console.warn(' OR add Google Translate API key to use paid translation');
}
// TODO: Add Google Translate API fallback here
// For now, return original text
console.warn('⚠️ No translation provider configured - returning original text');
return text;
}
// Cache management
function getFromCache(key) {
try {
const item = localStorage.getItem(`hydra_${key}`);
if (!item) return null;
const { value, expiry } = JSON.parse(item);
if (expiry && Date.now() > expiry) {
localStorage.removeItem(`hydra_${key}`);
return null;
}
return value;
} catch {
return null;
}
}
function saveToCache(key, value) {
try {
const item = {
value,
expiry: Date.now() + (30 * 24 * 60 * 60 * 1000) // 30 days
};
localStorage.setItem(`hydra_${key}`, JSON.stringify(item));
} catch (error) {
console.warn('Cache save failed:', error);
}
}
} // End of initializeTranslationSystem
})();
</script>
<script>
window.HYDRA_HELP_ENABLED = true;
function loadHelpSystemScript() {
if (window.HelpSystem && window.HelpSystem.openHub) return Promise.resolve();
return new Promise(function(resolve, reject) {
var existing = document.querySelector('script[data-hydra-help-system]');
if (existing) {
existing.addEventListener('load', function() { resolve(); });
existing.addEventListener('error', reject);
return;
}
var s = document.createElement('script');
s.src = '/api/help-system.js?v=686c85345749';
s.async = true;
s.setAttribute('data-hydra-help-system', '1');
s.onload = function() { resolve(); };
s.onerror = reject;
document.head.appendChild(s);
});
}
function openHydraHelp(opts) {
opts = opts || {};
if (!window.HYDRA_HELP_ENABLED) return;
var token = window.didToken || localStorage.getItem('hydra_auth_token');
var isAdmin = window.isAdmin || localStorage.getItem('hydra_is_admin') === 'true';
if (!token || !isAdmin) return;
var p = loadHelpSystemScript();
Promise.resolve(p).then(function() {
if (window.HelpSystem && window.HelpSystem.init) return window.HelpSystem.init();
}).then(function() {
if (window.HelpSystem && window.HelpSystem.openHub) {
window.HelpSystem.openHub(opts.initialTab ? { initialTab: opts.initialTab } : undefined);
}
}).catch(function(err) {
console.error('HelpSystem init failed:', err);
});
}
window.openHydraHelp = openHydraHelp;
</script>
<script type="module" src="https://static.cloudflareinsights.com/beacon.min.js/v4513226cdae34746b4dedf0b4dfa099e1781791509496" integrity="sha512-ZE9pZaUXND66v380QUtch/5sE9tPFh2zg45pR2PB0CVkCtOREv2AJKkSidISWkysEuQ0EH8faUU5du78bx87UQ==" data-cf-beacon='{"version":"2024.11.0","token":"5f8c17677ba84f8d88ba3d46ef47b608"}' crossorigin="anonymous"></script>
</body>
</html>
Sitemap
Кол-во: 0
XML-карта сайта для поисковиков
?
Sitemap.xml помогает поисковику быстрее находить и индексировать страницы. Особенно важен для крупных сайтов и новых страниц, на которые ещё нет входящих ссылок.
Robots.txt не содержит ссылку на карту сайта. Рекомендуется добавить карту сайта и указать ссылку на нее в robots.txt.
Внутренние ссылки
Кол-во: 1
Ссылки на другие страницы своего сайта
?
Внутренние ссылки распределяют ссылочный вес между страницами и помогают поисковику обходить сайт. Пустые анкоры и ссылки на запрещённые robots.txt страницы — типичные ошибки.
Внутренних ссылок на странице 1 оптимально.
Внутренние ссылки не запрещены к индексации в robots.txt.
Показать внутренние ссылки
| Url | Анкор | Состояние | Анализировать |
|---|---|---|---|
| /home |
Contact
|
|
Анализировать url |
Внешние ссылки
Не найдено
Ссылки на сторонние сайты
?
Исходящие внешние ссылки передают часть ссылочного веса на чужие сайты. Ссылки на авторитетные ресурсы безопасны; ссылки на мусорные сайты могут навредить репутации страницы.
Внешних ссылок на странице не найдено.
Конкуренты Готовность: 0%
Конкуренты в Яндексе
Кол-во: 0
Топ сайтов-конкурентов в Яндексе
?
Сайты, чаще всего появляющиеся в ТОПе Яндекса по запросам из семантического ядра этой страницы.
Мы не нашли у вас конкурентов в Яндексе. Сайт или очень молодой или плохо продвигается.
Конкурентов в ТОП-10 Яндекса не нашлось.
Конкуренты в Google
Кол-во: 0
Топ сайтов-конкурентов в Google
?
Сайты, чаще всего появляющиеся в ТОПе Google по запросам из семантического ядра этой страницы.
Конкуренты в Google тоже не найдены. Займитесь продвижением сайта!
Конкурентов в ТОП-10 Google не нашлось.
ЗоЗПП: права потребителей Готовность: 100%
Нарушения
Не выявлены
Признаков дистанционной продажи товаров (интернет-магазина) не обнаружено — требования ЗоЗПП о раскрытии информации продавца к сайту не применяются. Нарушений нет.
ФЗ-149: рекомендательные технологии Готовность: 100%
Нарушения
Не выявлены
Рекомендательные блоки («с этим покупают», «похожие товары» и т.п.) на сайте не обнаружены — требования ст. 10.7 ФЗ-149 к сайту не применяются. Нарушений нет.
ФЗ-38: реклама Готовность: 100%
Нарушения
Не выявлены
Рекламных тематик с обязательными оговорками (медицина, БАД, кредиты и займы, новостройки) на сайте не обнаружено. Нарушений нет.
ФЗ-436: защита детей Готовность: 100%
Нарушения
Не выявлены
Признаков информационной продукции (новости, видео, книги, игры, курсы) не обнаружено — обязательная возрастная маркировка по ФЗ-436 сайту не требуется. Нарушений нет.
Вердикт
Анализ сайта aphotostudent.com, слабо оптимизирован на 32%. Для хорошей оптимизации и выхода на первые места в поиске требуется:
Постарайтесь исправить общие ошибки.
Исправьте ошибки в мета-тегах.
Исправьте ошибки оптимизации.
Исправьте ошибки индексации.
Поделитесь с друзьями: